feat(customer): 完成客户画像会员摘要与权限链路
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m2s

This commit is contained in:
2026-03-03 14:39:33 +08:00
parent 1b28fa6db4
commit a993b81aeb
15 changed files with 3053 additions and 1 deletions

View File

@@ -0,0 +1,562 @@
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
/// <summary>
/// 客户列表筛选请求。
/// </summary>
public class CustomerListFilterRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 客户标签high_value/active/dormant/churn/new_customer
/// </summary>
public string? Tag { get; set; }
/// <summary>
/// 下单次数区间once/two_to_five/six_to_ten/ten_plus
/// </summary>
public string? OrderCountRange { get; set; }
/// <summary>
/// 注册周期7/30/90 或 7d/30d/90d
/// </summary>
public string? RegisterPeriod { get; set; }
}
/// <summary>
/// 客户列表分页请求。
/// </summary>
public sealed class CustomerListRequest : CustomerListFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 客户详情请求。
/// </summary>
public sealed class CustomerDetailRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
}
/// <summary>
/// 客户画像请求。
/// </summary>
public sealed class CustomerProfileRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
}
/// <summary>
/// 客户标签响应。
/// </summary>
public sealed class CustomerTagResponse
{
/// <summary>
/// 标签编码。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 标签文案。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 标签色调orange/blue/green/gray/red
/// </summary>
public string Tone { get; set; } = "blue";
}
/// <summary>
/// 客户列表行响应。
/// </summary>
public sealed class CustomerListItemResponse
{
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; set; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; set; } = string.Empty;
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 下单次数条形宽度百分比。
/// </summary>
public int OrderCountBarPercent { get; set; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 最近下单时间yyyy-MM-dd
/// </summary>
public string LastOrderAt { get; set; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public List<CustomerTagResponse> Tags { get; set; } = [];
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; set; }
}
/// <summary>
/// 客户列表响应。
/// </summary>
public sealed class CustomerListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<CustomerListItemResponse> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 客户列表统计响应。
/// </summary>
public sealed class CustomerListStatsResponse
{
/// <summary>
/// 客户总数。
/// </summary>
public int TotalCustomers { get; set; }
/// <summary>
/// 本月新增客户数。
/// </summary>
public int MonthlyNewCustomers { get; set; }
/// <summary>
/// 本月较上月增长百分比。
/// </summary>
public decimal MonthlyGrowthRatePercent { get; set; }
/// <summary>
/// 活跃客户数(近 30 天有下单)。
/// </summary>
public int ActiveCustomers { get; set; }
/// <summary>
/// 近 30 天客均消费(按订单均值)。
/// </summary>
public decimal AverageAmountLast30Days { get; set; }
}
/// <summary>
/// 客户偏好响应。
/// </summary>
public sealed class CustomerPreferenceResponse
{
/// <summary>
/// 偏好品类。
/// </summary>
public List<string> PreferredCategories { get; set; } = [];
/// <summary>
/// 偏好下单时段。
/// </summary>
public string PreferredOrderPeaks { get; set; } = string.Empty;
/// <summary>
/// 偏好履约方式。
/// </summary>
public string PreferredDelivery { get; set; } = string.Empty;
/// <summary>
/// 偏好支付方式。
/// </summary>
public string PreferredPaymentMethod { get; set; } = string.Empty;
/// <summary>
/// 平均配送距离文案(当前无配送距离数据时返回空字符串)。
/// </summary>
public string AverageDeliveryDistance { get; set; } = string.Empty;
}
/// <summary>
/// 客户常购商品响应。
/// </summary>
public sealed class CustomerTopProductResponse
{
/// <summary>
/// 排名。
/// </summary>
public int Rank { get; set; }
/// <summary>
/// 商品名称。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 购买次数。
/// </summary>
public int Count { get; set; }
/// <summary>
/// 占比0-100
/// </summary>
public decimal ProportionPercent { get; set; }
}
/// <summary>
/// 客户月度趋势响应。
/// </summary>
public sealed class CustomerTrendPointResponse
{
/// <summary>
/// 月份标签。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 消费金额。
/// </summary>
public decimal Amount { get; set; }
}
/// <summary>
/// 客户最近订单响应。
/// </summary>
public sealed class CustomerRecentOrderResponse
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 订单金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 商品摘要。
/// </summary>
public string ItemsSummary { get; set; } = string.Empty;
/// <summary>
/// 履约方式。
/// </summary>
public string DeliveryType { get; set; } = string.Empty;
/// <summary>
/// 订单状态。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 下单时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string OrderedAt { get; set; } = string.Empty;
}
/// <summary>
/// 客户会员摘要响应。
/// </summary>
public sealed class CustomerMemberSummaryResponse
{
/// <summary>
/// 是否会员。
/// </summary>
public bool IsMember { get; set; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; set; } = string.Empty;
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; set; }
/// <summary>
/// 成长值。
/// </summary>
public int GrowthValue { get; set; }
/// <summary>
/// 入会时间yyyy-MM-dd
/// </summary>
public string JoinedAt { get; set; } = string.Empty;
}
/// <summary>
/// 客户详情响应。
/// </summary>
public sealed class CustomerDetailResponse
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 注册时间yyyy-MM-dd
/// </summary>
public string RegisteredAt { get; set; } = string.Empty;
/// <summary>
/// 首次下单时间yyyy-MM-dd
/// </summary>
public string FirstOrderAt { get; set; } = string.Empty;
/// <summary>
/// 客户来源。
/// </summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public List<CustomerTagResponse> Tags { get; set; } = [];
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryResponse Member { get; set; } = new();
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; set; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 复购率(百分比)。
/// </summary>
public decimal RepurchaseRatePercent { get; set; }
/// <summary>
/// 消费偏好。
/// </summary>
public CustomerPreferenceResponse Preference { get; set; } = new();
/// <summary>
/// 常购商品 Top 5。
/// </summary>
public List<CustomerTopProductResponse> TopProducts { get; set; } = [];
/// <summary>
/// 近 6 月消费趋势。
/// </summary>
public List<CustomerTrendPointResponse> Trend { get; set; } = [];
/// <summary>
/// 最近订单(最多 3 条)。
/// </summary>
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
}
/// <summary>
/// 客户画像响应。
/// </summary>
public sealed class CustomerProfileResponse
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 注册时间yyyy-MM-dd
/// </summary>
public string RegisteredAt { get; set; } = string.Empty;
/// <summary>
/// 首次下单时间yyyy-MM-dd
/// </summary>
public string FirstOrderAt { get; set; } = string.Empty;
/// <summary>
/// 客户来源。
/// </summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public List<CustomerTagResponse> Tags { get; set; } = [];
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryResponse Member { get; set; } = new();
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; set; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 复购率(百分比)。
/// </summary>
public decimal RepurchaseRatePercent { get; set; }
/// <summary>
/// 平均下单间隔(天)。
/// </summary>
public decimal AverageOrderIntervalDays { get; set; }
/// <summary>
/// 消费偏好。
/// </summary>
public CustomerPreferenceResponse Preference { get; set; } = new();
/// <summary>
/// 常购商品 Top 5。
/// </summary>
public List<CustomerTopProductResponse> TopProducts { get; set; } = [];
/// <summary>
/// 近 12 月消费趋势。
/// </summary>
public List<CustomerTrendPointResponse> Trend { get; set; } = [];
/// <summary>
/// 最近订单(最多 5 条)。
/// </summary>
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
}
/// <summary>
/// 客户导出响应。
/// </summary>
public sealed class CustomerExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件 Base64。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,392 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Customer;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 客户管理列表与画像。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/customer/list")]
public sealed class CustomerController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:customer:list:view";
private const string ManagePermission = "tenant:customer:list:manage";
private const string ProfilePermission = "tenant:customer:profile:view";
/// <summary>
/// 获取客户列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerListResultResponse>> List(
[FromQuery] CustomerListRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new SearchCustomerListQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
Tag = request.Tag,
OrderCountRange = request.OrderCountRange,
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod),
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
return ApiResponse<CustomerListResultResponse>.Ok(new CustomerListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize
});
}
/// <summary>
/// 获取客户列表统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerListStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerListStatsResponse>> Stats(
[FromQuery] CustomerListFilterRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetCustomerListStatsQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
Tag = request.Tag,
OrderCountRange = request.OrderCountRange,
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
}, cancellationToken);
return ApiResponse<CustomerListStatsResponse>.Ok(new CustomerListStatsResponse
{
TotalCustomers = result.TotalCustomers,
MonthlyNewCustomers = result.MonthlyNewCustomers,
MonthlyGrowthRatePercent = result.MonthlyGrowthRatePercent,
ActiveCustomers = result.ActiveCustomers,
AverageAmountLast30Days = result.AverageAmountLast30Days
});
}
/// <summary>
/// 获取客户详情(一级抽屉)。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
[FromQuery] CustomerDetailRequest request,
CancellationToken cancellationToken)
{
var customerKey = NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey))
{
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
}
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetCustomerDetailQuery
{
VisibleStoreIds = visibleStoreIds,
CustomerKey = customerKey
}, cancellationToken);
if (result is null)
{
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
}
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 获取客户画像(二级抽屉)。
/// </summary>
[HttpGet("profile")]
[PermissionAuthorize(ProfilePermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
[FromQuery] CustomerProfileRequest request,
CancellationToken cancellationToken)
{
var customerKey = NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey))
{
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
}
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetCustomerProfileQuery
{
VisibleStoreIds = visibleStoreIds,
CustomerKey = customerKey
}, cancellationToken);
if (result is null)
{
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
}
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
}
/// <summary>
/// 导出客户 CSV。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerExportResponse>> Export(
[FromQuery] CustomerListFilterRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new ExportCustomerCsvQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
Tag = request.Tag,
OrderCountRange = request.OrderCountRange,
RegisterPeriodDays = ParseRegisterPeriodDays(request.RegisterPeriod)
}, cancellationToken);
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
string? storeId,
CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
if (!string.IsNullOrWhiteSpace(storeId))
{
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
await StoreApiHelpers.EnsureStoreAccessibleAsync(
dbContext,
tenantId,
merchantId,
parsedStoreId,
cancellationToken);
return [parsedStoreId];
}
var allStoreIds = await dbContext.Stores
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
.Select(item => item.Id)
.OrderBy(item => item)
.ToListAsync(cancellationToken);
if (allStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
}
return allStoreIds;
}
private static int? ParseRegisterPeriodDays(string? registerPeriod)
{
var normalized = (registerPeriod ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
return normalized switch
{
"7" or "7d" => 7,
"30" or "30d" => 30,
"90" or "90d" => 90,
_ => throw new BusinessException(ErrorCodes.BadRequest, "registerPeriod 参数不合法")
};
}
private static string NormalizePhone(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return chars.Length == 0 ? string.Empty : new string(chars);
}
private static CustomerListItemResponse MapListItem(CustomerListItemDto source)
{
return new CustomerListItemResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
AvatarText = source.AvatarText,
AvatarColor = source.AvatarColor,
OrderCount = source.OrderCount,
OrderCountBarPercent = source.OrderCountBarPercent,
TotalAmount = source.TotalAmount,
AverageAmount = source.AverageAmount,
LastOrderAt = ToDateOnly(source.LastOrderAt),
Tags = source.Tags.Select(MapTag).ToList(),
IsDimmed = source.IsDimmed
};
}
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
{
return new CustomerDetailResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
RegisteredAt = ToDateOnly(source.RegisteredAt),
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
Source = source.Source,
Tags = source.Tags.Select(MapTag).ToList(),
Member = MapMember(source.Member),
TotalOrders = source.TotalOrders,
TotalAmount = source.TotalAmount,
AverageAmount = source.AverageAmount,
RepurchaseRatePercent = source.RepurchaseRatePercent,
Preference = MapPreference(source.Preference),
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
Trend = source.Trend.Select(MapTrend).ToList(),
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
};
}
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
{
return new CustomerProfileResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
RegisteredAt = ToDateOnly(source.RegisteredAt),
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
Source = source.Source,
Tags = source.Tags.Select(MapTag).ToList(),
Member = MapMember(source.Member),
TotalOrders = source.TotalOrders,
TotalAmount = source.TotalAmount,
AverageAmount = source.AverageAmount,
RepurchaseRatePercent = source.RepurchaseRatePercent,
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
Preference = MapPreference(source.Preference),
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
Trend = source.Trend.Select(MapTrend).ToList(),
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
};
}
private static CustomerTagResponse MapTag(CustomerTagDto source)
{
return new CustomerTagResponse
{
Code = source.Code,
Label = source.Label,
Tone = source.Tone
};
}
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
{
return new CustomerPreferenceResponse
{
PreferredCategories = source.PreferredCategories.ToList(),
PreferredOrderPeaks = source.PreferredOrderPeaks,
PreferredDelivery = source.PreferredDelivery,
PreferredPaymentMethod = source.PreferredPaymentMethod,
AverageDeliveryDistance = source.AverageDeliveryDistance
};
}
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
{
return new CustomerMemberSummaryResponse
{
IsMember = source.IsMember,
TierName = source.TierName,
PointsBalance = source.PointsBalance,
GrowthValue = source.GrowthValue,
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
};
}
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
{
return new CustomerTopProductResponse
{
Rank = source.Rank,
ProductName = source.ProductName,
Count = source.Count,
ProportionPercent = source.ProportionPercent
};
}
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
{
return new CustomerTrendPointResponse
{
Label = source.Label,
Amount = source.Amount
};
}
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
{
return new CustomerRecentOrderResponse
{
OrderNo = source.OrderNo,
Amount = source.Amount,
ItemsSummary = source.ItemsSummary,
DeliveryType = source.DeliveryType,
Status = source.Status,
OrderedAt = ToDateTime(source.OrderedAt)
};
}
private static string ToDateOnly(DateTime value)
{
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,462 @@
namespace TakeoutSaaS.Application.App.Customers.Dto;
/// <summary>
/// 客户标签 DTO。
/// </summary>
public sealed class CustomerTagDto
{
/// <summary>
/// 标签编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 标签文案。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 标签色调orange/blue/green/gray/red
/// </summary>
public string Tone { get; init; } = "blue";
}
/// <summary>
/// 客户列表行 DTO。
/// </summary>
public sealed class CustomerListItemDto
{
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; init; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; init; } = string.Empty;
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 下单次数条形宽度百分比。
/// </summary>
public int OrderCountBarPercent { get; init; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; init; }
}
/// <summary>
/// 客户列表统计 DTO。
/// </summary>
public sealed class CustomerListStatsDto
{
/// <summary>
/// 客户总数。
/// </summary>
public int TotalCustomers { get; init; }
/// <summary>
/// 本月新增客户数。
/// </summary>
public int MonthlyNewCustomers { get; init; }
/// <summary>
/// 本月较上月增长百分比。
/// </summary>
public decimal MonthlyGrowthRatePercent { get; init; }
/// <summary>
/// 活跃客户数(近 30 天有下单)。
/// </summary>
public int ActiveCustomers { get; init; }
/// <summary>
/// 近 30 天客均消费(按订单均值)。
/// </summary>
public decimal AverageAmountLast30Days { get; init; }
}
/// <summary>
/// 客户偏好 DTO。
/// </summary>
public sealed class CustomerPreferenceDto
{
/// <summary>
/// 偏好品类。
/// </summary>
public IReadOnlyList<string> PreferredCategories { get; init; } = [];
/// <summary>
/// 偏好下单时段。
/// </summary>
public string PreferredOrderPeaks { get; init; } = string.Empty;
/// <summary>
/// 偏好履约方式。
/// </summary>
public string PreferredDelivery { get; init; } = string.Empty;
/// <summary>
/// 偏好支付方式。
/// </summary>
public string PreferredPaymentMethod { get; init; } = string.Empty;
/// <summary>
/// 平均配送距离文案。
/// </summary>
public string AverageDeliveryDistance { get; init; } = string.Empty;
}
/// <summary>
/// 客户常购商品 DTO。
/// </summary>
public sealed class CustomerTopProductDto
{
/// <summary>
/// 排名。
/// </summary>
public int Rank { get; init; }
/// <summary>
/// 商品名称。
/// </summary>
public string ProductName { get; init; } = string.Empty;
/// <summary>
/// 购买次数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 占比0-100
/// </summary>
public decimal ProportionPercent { get; init; }
}
/// <summary>
/// 客户趋势点 DTO。
/// </summary>
public sealed class CustomerTrendPointDto
{
/// <summary>
/// 月份标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 消费金额。
/// </summary>
public decimal Amount { get; init; }
}
/// <summary>
/// 客户最近订单 DTO。
/// </summary>
public sealed class CustomerRecentOrderDto
{
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; init; } = string.Empty;
/// <summary>
/// 下单时间。
/// </summary>
public DateTime OrderedAt { get; init; }
/// <summary>
/// 订单金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 商品摘要。
/// </summary>
public string ItemsSummary { get; init; } = string.Empty;
/// <summary>
/// 履约方式文案。
/// </summary>
public string DeliveryType { get; init; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string Status { get; init; } = string.Empty;
}
/// <summary>
/// 客户会员摘要 DTO。
/// </summary>
public sealed class CustomerMemberSummaryDto
{
/// <summary>
/// 是否会员。
/// </summary>
public bool IsMember { get; init; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; init; } = string.Empty;
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; init; }
/// <summary>
/// 成长值。
/// </summary>
public int GrowthValue { get; init; }
/// <summary>
/// 入会时间。
/// </summary>
public DateTime? JoinedAt { get; init; }
}
/// <summary>
/// 客户详情 DTO。
/// </summary>
public sealed class CustomerDetailDto
{
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 首次下单时间。
/// </summary>
public DateTime FirstOrderAt { get; init; }
/// <summary>
/// 客户来源。
/// </summary>
public string Source { get; init; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryDto Member { get; init; } = new();
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; init; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 复购率(百分比)。
/// </summary>
public decimal RepurchaseRatePercent { get; init; }
/// <summary>
/// 偏好数据。
/// </summary>
public CustomerPreferenceDto Preference { get; init; } = new();
/// <summary>
/// 常购商品 Top 5。
/// </summary>
public IReadOnlyList<CustomerTopProductDto> TopProducts { get; init; } = [];
/// <summary>
/// 趋势数据。
/// </summary>
public IReadOnlyList<CustomerTrendPointDto> Trend { get; init; } = [];
/// <summary>
/// 最近订单。
/// </summary>
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
}
/// <summary>
/// 客户画像 DTO。
/// </summary>
public sealed class CustomerProfileDto
{
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 首次下单时间。
/// </summary>
public DateTime FirstOrderAt { get; init; }
/// <summary>
/// 客户来源。
/// </summary>
public string Source { get; init; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryDto Member { get; init; } = new();
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; init; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 复购率(百分比)。
/// </summary>
public decimal RepurchaseRatePercent { get; init; }
/// <summary>
/// 平均下单间隔(天)。
/// </summary>
public decimal AverageOrderIntervalDays { get; init; }
/// <summary>
/// 偏好数据。
/// </summary>
public CustomerPreferenceDto Preference { get; init; } = new();
/// <summary>
/// 常购商品 Top 5。
/// </summary>
public IReadOnlyList<CustomerTopProductDto> TopProducts { get; init; } = [];
/// <summary>
/// 趋势数据。
/// </summary>
public IReadOnlyList<CustomerTrendPointDto> Trend { get; init; } = [];
/// <summary>
/// 最近订单。
/// </summary>
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
}
/// <summary>
/// 客户导出 DTO。
/// </summary>
public sealed class CustomerExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件 Base64。
/// </summary>
public string FileContentBase64 { get; init; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,992 @@
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Domain.Orders.Entities;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
internal static class CustomerAnalyticsSupport
{
private static readonly string[] AvatarColors =
[
"#f56a00",
"#7265e6",
"#52c41a",
"#fa8c16",
"#1890ff",
"#bfbfbf",
"#13c2c2",
"#eb2f96"
];
internal const string TagHighValue = "high_value";
internal const string TagActive = "active";
internal const string TagDormant = "dormant";
internal const string TagChurn = "churn";
internal const string TagNewCustomer = "new_customer";
internal static readonly string[] SupportedTags =
[
TagHighValue,
TagActive,
TagDormant,
TagChurn,
TagNewCustomer
];
internal static string NormalizePhone(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return chars.Length == 0 ? string.Empty : new string(chars);
}
internal static string MaskPhone(string normalizedPhone)
{
if (normalizedPhone.Length >= 11)
{
return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}";
}
if (normalizedPhone.Length >= 7)
{
return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}";
}
return normalizedPhone;
}
internal static string ResolveAvatarText(string name, string customerKey)
{
var candidate = (name ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(candidate))
{
return candidate[..1];
}
return customerKey.Length > 0 ? customerKey[..1] : "客";
}
internal static string ResolveAvatarColor(string? seed)
{
var source = string.IsNullOrWhiteSpace(seed) ? "customer" : seed;
var hash = 0;
foreach (var ch in source)
{
hash = (hash * 31 + ch) & int.MaxValue;
}
return AvatarColors[hash % AvatarColors.Length];
}
internal static decimal ResolveDisplayAmount(Order order)
{
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
}
internal static string ToDeliveryTypeText(DeliveryType value)
{
return value switch
{
DeliveryType.Delivery => "外卖",
DeliveryType.Pickup => "自提",
DeliveryType.DineIn => "堂食",
_ => "未知"
};
}
internal static string ToPaymentMethodText(PaymentMethod value)
{
return value switch
{
PaymentMethod.WeChatPay => "微信支付",
PaymentMethod.Alipay => "支付宝",
PaymentMethod.Balance => "余额支付",
PaymentMethod.Cash => "现金",
PaymentMethod.Card => "刷卡",
_ => "--"
};
}
internal static string ToOrderStatusText(OrderStatus status, DeliveryType deliveryType)
{
return status switch
{
OrderStatus.PendingPayment => "待接单",
OrderStatus.AwaitingPreparation => "待接单",
OrderStatus.InProgress => "制作中",
OrderStatus.Ready => deliveryType == DeliveryType.Delivery ? "配送中" : "待取餐",
OrderStatus.Completed => "已完成",
OrderStatus.Cancelled => "已取消",
_ => "未知"
};
}
internal static decimal ToRatePercent(int numerator, int denominator)
{
if (denominator <= 0 || numerator <= 0)
{
return 0;
}
return decimal.Round(
numerator * 100m / denominator,
1,
MidpointRounding.AwayFromZero);
}
internal static decimal ToGrowthRatePercent(int current, int previous)
{
if (previous <= 0)
{
return current <= 0 ? 0 : 100;
}
return decimal.Round(
(current - previous) * 100m / previous,
1,
MidpointRounding.AwayFromZero);
}
internal static string NormalizeTag(string? tag)
{
var normalized = (tag ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"highvalue" or "high_value" or "high-value" => TagHighValue,
"active" => TagActive,
"dormant" or "sleeping" => TagDormant,
"churn" or "lost" => TagChurn,
"new" or "new_customer" or "new-customer" => TagNewCustomer,
_ => string.Empty
};
}
internal static string NormalizeOrderCountRange(string? range)
{
var normalized = (range ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"once" or "one" or "1" => "once",
"two_to_five" or "2_5" or "2-5" => "two_to_five",
"six_to_ten" or "6_10" or "6-10" => "six_to_ten",
"ten_plus" or "10+" or "more_than_ten" => "ten_plus",
_ => string.Empty
};
}
internal static bool MatchOrderCountRange(int orderCount, string normalizedRange)
{
return normalizedRange switch
{
"once" => orderCount == 1,
"two_to_five" => orderCount >= 2 && orderCount <= 5,
"six_to_ten" => orderCount >= 6 && orderCount <= 10,
"ten_plus" => orderCount > 10,
_ => true
};
}
internal static IReadOnlyList<CustomerTagDto> BuildTags(
decimal totalAmount,
decimal averageAmount,
int orderCount,
DateTime registeredAt,
DateTime lastOrderAt,
DateTime nowUtc)
{
var tagList = new List<CustomerTagDto>();
// 1. 计算基础状态
var silentDays = (nowUtc.Date - lastOrderAt.Date).TotalDays;
var isHighValue = totalAmount >= 3000m || (averageAmount >= 100m && orderCount >= 10);
var isNewCustomer = registeredAt >= nowUtc.AddDays(-30);
var isActive = silentDays <= 30;
var isDormant = silentDays > 30 && silentDays <= 60;
var isChurn = silentDays > 60;
// 2. 组合标签(优先保留原型主标签)
if (isHighValue)
{
tagList.Add(new CustomerTagDto
{
Code = TagHighValue,
Label = "高价值",
Tone = "orange"
});
}
if (isNewCustomer)
{
tagList.Add(new CustomerTagDto
{
Code = TagNewCustomer,
Label = "新客户",
Tone = "green"
});
return tagList;
}
if (isActive)
{
tagList.Add(new CustomerTagDto
{
Code = TagActive,
Label = "活跃",
Tone = "blue"
});
return tagList;
}
if (isDormant)
{
tagList.Add(new CustomerTagDto
{
Code = TagDormant,
Label = "沉睡",
Tone = "gray"
});
return tagList;
}
if (isChurn)
{
tagList.Add(new CustomerTagDto
{
Code = TagChurn,
Label = "流失",
Tone = "red"
});
}
return tagList;
}
internal static string ResolveCustomerName(
string customerKey,
string latestName,
MemberProfileSnapshot? memberProfile)
{
if (!string.IsNullOrWhiteSpace(memberProfile?.Nickname))
{
return memberProfile.Nickname.Trim();
}
if (!string.IsNullOrWhiteSpace(latestName))
{
return latestName.Trim();
}
return customerKey.Length >= 4 ? $"客户{customerKey[^4..]}" : "客户";
}
internal static CustomerMemberSummaryDto BuildMemberSummary(MemberProfileSnapshot? memberProfile)
{
if (memberProfile is null)
{
return new CustomerMemberSummaryDto
{
IsMember = false,
TierName = string.Empty,
PointsBalance = 0,
GrowthValue = 0,
JoinedAt = null
};
}
return new CustomerMemberSummaryDto
{
IsMember = true,
TierName = string.IsNullOrWhiteSpace(memberProfile.TierName)
? string.Empty
: memberProfile.TierName.Trim(),
PointsBalance = Math.Max(0, memberProfile.PointsBalance),
GrowthValue = Math.Max(0, memberProfile.GrowthValue),
JoinedAt = memberProfile.JoinedAt
};
}
internal static IReadOnlyList<CustomerAggregate> ApplyFilters(
IReadOnlyList<CustomerAggregate> customers,
string? keyword,
string? normalizedTag,
string? normalizedOrderCountRange,
int? registerPeriodDays,
DateTime nowUtc)
{
var normalizedKeyword = (keyword ?? string.Empty).Trim();
var keywordDigits = NormalizePhone(normalizedKeyword);
return customers
.Where(customer =>
{
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
if (!matchedByName && !matchedByPhone)
{
return false;
}
}
if (!string.IsNullOrWhiteSpace(normalizedTag) &&
!customer.Tags.Any(tag => string.Equals(tag.Code, normalizedTag, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
if (!string.IsNullOrWhiteSpace(normalizedOrderCountRange) &&
!MatchOrderCountRange(customer.OrderCount, normalizedOrderCountRange))
{
return false;
}
if (registerPeriodDays.HasValue && registerPeriodDays.Value > 0)
{
var threshold = nowUtc.AddDays(-registerPeriodDays.Value);
if (customer.RegisteredAt < threshold)
{
return false;
}
}
return true;
})
.ToList();
}
internal static IReadOnlyList<CustomerTrendPointDto> BuildMonthlyTrend(
IReadOnlyList<CustomerOrderSnapshot> orders,
DateTime nowUtc,
int monthCount)
{
var normalizedMonthCount = Math.Clamp(monthCount, 1, 24);
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var windowStart = monthStart.AddMonths(-normalizedMonthCount + 1);
// 1. 预计算窗口内订单金额
var monthAmountMap = orders
.Where(item => item.OrderedAt >= windowStart && item.OrderedAt < monthStart.AddMonths(1))
.GroupBy(item => new DateTime(item.OrderedAt.Year, item.OrderedAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
.ToDictionary(group => group.Key, group => group.Sum(item => item.Amount));
// 2. 生成连续月份点
var trend = new List<CustomerTrendPointDto>(normalizedMonthCount);
for (var index = 0; index < normalizedMonthCount; index += 1)
{
var currentMonth = windowStart.AddMonths(index);
monthAmountMap.TryGetValue(currentMonth, out var amount);
trend.Add(new CustomerTrendPointDto
{
Label = $"{currentMonth.Month}月",
Amount = decimal.Round(amount, 2, MidpointRounding.AwayFromZero)
});
}
return trend;
}
internal static decimal CalculateAverageIntervalDays(IReadOnlyList<CustomerOrderSnapshot> orders)
{
if (orders.Count < 2)
{
return 0;
}
var ascOrders = orders
.OrderBy(item => item.OrderedAt)
.ThenBy(item => item.OrderId)
.ToList();
var totalDays = 0m;
for (var index = 1; index < ascOrders.Count; index += 1)
{
totalDays += (decimal)(ascOrders[index].OrderedAt - ascOrders[index - 1].OrderedAt).TotalDays;
}
return decimal.Round(totalDays / (ascOrders.Count - 1), 1, MidpointRounding.AwayFromZero);
}
internal static string ResolvePreferredOrderPeaks(IReadOnlyList<CustomerOrderSnapshot> orders)
{
if (orders.Count == 0)
{
return string.Empty;
}
var slots = orders
.GroupBy(item => ResolvePeakSlot(item.OrderedAt.Hour))
.Select(group => new
{
Slot = group.Key,
Count = group.Count()
})
.OrderByDescending(item => item.Count)
.ThenBy(item => item.Slot, StringComparer.Ordinal)
.Take(2)
.Select(item => item.Slot)
.ToList();
return slots.Count == 0 ? string.Empty : string.Join("、", slots);
}
internal static string ResolvePreferredDelivery(IReadOnlyList<CustomerOrderSnapshot> orders)
{
if (orders.Count == 0)
{
return string.Empty;
}
var grouped = orders
.GroupBy(item => item.DeliveryType)
.Select(group => new
{
Type = group.Key,
Count = group.Count()
})
.OrderByDescending(item => item.Count)
.ThenBy(item => item.Type)
.ToList();
var totalCount = grouped.Sum(item => item.Count);
if (totalCount <= 0)
{
return string.Empty;
}
return string.Join("、", grouped.Select(item =>
{
var ratio = ToRatePercent(item.Count, totalCount);
return $"{ToDeliveryTypeText(item.Type)} ({ratio:0.#}%)";
}));
}
internal static async Task<string> ResolvePreferredPaymentMethodAsync(
IOrderRepository orderRepository,
long tenantId,
IReadOnlyList<CustomerOrderSnapshot> orders,
CancellationToken cancellationToken)
{
if (orders.Count == 0)
{
return string.Empty;
}
// 1. 控制计算成本,仅统计最近 60 单
var recentOrders = orders
.OrderByDescending(item => item.OrderedAt)
.ThenByDescending(item => item.OrderId)
.Take(60)
.ToList();
// 2. 统计支付方式
var counter = new Dictionary<PaymentMethod, int>();
foreach (var order in recentOrders)
{
var payment = await orderRepository.GetLatestPaymentRecordAsync(order.OrderId, tenantId, cancellationToken);
if (payment is null)
{
continue;
}
if (!counter.TryAdd(payment.Method, 1))
{
counter[payment.Method] += 1;
}
}
if (counter.Count == 0)
{
return string.Empty;
}
return counter
.OrderByDescending(item => item.Value)
.ThenBy(item => item.Key)
.Select(item => $"{ToPaymentMethodText(item.Key)} ({item.Value}次)")
.FirstOrDefault() ?? string.Empty;
}
internal static IReadOnlyList<CustomerTopProductDto> BuildTopProducts(
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
IReadOnlyList<long> orderIds,
int takeCount)
{
if (orderIds.Count == 0 || takeCount <= 0)
{
return [];
}
// 1. 汇总商品购买次数
var productCounter = new Dictionary<string, ProductCounter>();
foreach (var orderId in orderIds)
{
if (!itemsLookup.TryGetValue(orderId, out var items))
{
continue;
}
foreach (var item in items)
{
var normalizedName = string.IsNullOrWhiteSpace(item.ProductName) ? "商品" : item.ProductName.Trim();
var key = item.ProductId > 0 ? $"id:{item.ProductId}" : $"name:{normalizedName}";
var quantity = Math.Max(1, item.Quantity);
if (!productCounter.TryGetValue(key, out var counter))
{
counter = new ProductCounter
{
ProductId = item.ProductId,
ProductName = normalizedName,
Count = quantity
};
productCounter[key] = counter;
continue;
}
counter.Count += quantity;
}
}
var sorted = productCounter.Values
.OrderByDescending(item => item.Count)
.ThenBy(item => item.ProductName, StringComparer.Ordinal)
.Take(takeCount)
.ToList();
if (sorted.Count == 0)
{
return [];
}
var maxCount = Math.Max(1, sorted[0].Count);
return sorted
.Select((item, index) => new CustomerTopProductDto
{
Rank = index + 1,
ProductName = item.ProductName,
Count = item.Count,
ProportionPercent = decimal.Round(item.Count * 100m / maxCount, 1, MidpointRounding.AwayFromZero)
})
.ToList();
}
internal static async Task<IReadOnlyList<string>> ResolvePreferredCategoriesAsync(
IProductRepository productRepository,
long tenantId,
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
IReadOnlyList<long> orderIds,
CancellationToken cancellationToken)
{
if (orderIds.Count == 0)
{
return [];
}
// 1. 汇总分类出现频次
var productCache = new Dictionary<long, long>();
var categoryCounter = new Dictionary<long, int>();
foreach (var orderId in orderIds)
{
if (!itemsLookup.TryGetValue(orderId, out var items))
{
continue;
}
foreach (var item in items)
{
if (item.ProductId <= 0)
{
continue;
}
if (!productCache.TryGetValue(item.ProductId, out var categoryId))
{
var product = await productRepository.FindByIdAsync(item.ProductId, tenantId, cancellationToken);
categoryId = product?.CategoryId ?? 0;
productCache[item.ProductId] = categoryId;
}
if (categoryId <= 0)
{
continue;
}
var quantity = Math.Max(1, item.Quantity);
if (!categoryCounter.TryAdd(categoryId, quantity))
{
categoryCounter[categoryId] += quantity;
}
}
}
if (categoryCounter.Count == 0)
{
return [];
}
// 2. 读取分类名称并返回前 3
var categoryIds = categoryCounter
.OrderByDescending(item => item.Value)
.ThenBy(item => item.Key)
.Take(3)
.Select(item => item.Key)
.ToList();
var categoryNames = new List<string>(categoryIds.Count);
foreach (var categoryId in categoryIds)
{
var category = await productRepository.FindCategoryByIdAsync(categoryId, tenantId, cancellationToken);
if (category is null || string.IsNullOrWhiteSpace(category.Name))
{
continue;
}
categoryNames.Add(category.Name.Trim());
}
return categoryNames;
}
internal static IReadOnlyList<CustomerRecentOrderDto> BuildRecentOrders(
IReadOnlyList<CustomerOrderSnapshot> orders,
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup,
int takeCount)
{
return orders
.OrderByDescending(item => item.OrderedAt)
.ThenByDescending(item => item.OrderId)
.Take(Math.Max(1, takeCount))
.Select(item => new CustomerRecentOrderDto
{
OrderNo = item.OrderNo,
OrderedAt = item.OrderedAt,
Amount = item.Amount,
ItemsSummary = BuildItemsSummary(item.OrderId, itemsLookup),
DeliveryType = ToDeliveryTypeText(item.DeliveryType),
Status = ToOrderStatusText(item.Status, item.DeliveryType)
})
.ToList();
}
internal static string BuildItemsSummary(
long orderId,
IReadOnlyDictionary<long, IReadOnlyList<OrderItem>> itemsLookup)
{
if (!itemsLookup.TryGetValue(orderId, out var items) || items.Count == 0)
{
return "--";
}
var summaries = items
.Take(3)
.Select(item =>
{
var productName = string.IsNullOrWhiteSpace(item.ProductName) ? "商品" : item.ProductName.Trim();
var quantity = Math.Max(1, item.Quantity);
return $"{productName} x{quantity}";
})
.ToList();
if (items.Count > 3)
{
summaries.Add("等");
}
return string.Join("、", summaries);
}
internal static async Task<IReadOnlyList<CustomerAggregate>> LoadCustomersAsync(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider,
IReadOnlyCollection<long> visibleStoreIds,
CancellationToken cancellationToken)
{
if (visibleStoreIds.Count == 0)
{
return [];
}
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId <= 0)
{
return [];
}
var visibleStoreSet = visibleStoreIds.ToHashSet();
var rawOrders = await orderRepository.SearchAllOrdersAsync(
tenantId,
null,
null,
null,
null,
null,
null,
null,
cancellationToken);
// 1. 过滤可见门店并构建订单快照
var orderSnapshots = rawOrders
.Where(item => visibleStoreSet.Contains(item.StoreId))
.Select(item =>
{
var customerKey = NormalizePhone(item.CustomerPhone);
return new CustomerOrderSnapshot
{
OrderId = item.Id,
OrderNo = item.OrderNo,
StoreId = item.StoreId,
CustomerKey = customerKey,
CustomerName = string.IsNullOrWhiteSpace(item.CustomerName) ? string.Empty : item.CustomerName.Trim(),
OrderedAt = item.CreatedAt,
Amount = ResolveDisplayAmount(item),
DeliveryType = item.DeliveryType,
Status = item.Status
};
})
.Where(item => !string.IsNullOrWhiteSpace(item.CustomerKey))
.ToList();
if (orderSnapshots.Count == 0)
{
return [];
}
var customerKeys = orderSnapshots
.Select(item => item.CustomerKey)
.Distinct(StringComparer.Ordinal)
.ToHashSet(StringComparer.Ordinal);
var memberLookup = await LoadMemberProfileLookupAsync(
dapperExecutor,
tenantId,
customerKeys,
cancellationToken);
var nowUtc = DateTime.UtcNow;
return orderSnapshots
.GroupBy(item => item.CustomerKey, StringComparer.Ordinal)
.Select(group =>
{
var customerOrders = group
.OrderByDescending(item => item.OrderedAt)
.ThenByDescending(item => item.OrderId)
.ToList();
var firstOrderAt = customerOrders.Min(item => item.OrderedAt);
var lastOrderAt = customerOrders.Max(item => item.OrderedAt);
var orderCount = customerOrders.Count;
var totalAmount = customerOrders.Sum(item => item.Amount);
var averageAmount = orderCount == 0
? 0
: decimal.Round(totalAmount / orderCount, 2, MidpointRounding.AwayFromZero);
memberLookup.TryGetValue(group.Key, out var memberProfile);
var registeredAt = firstOrderAt;
if (memberProfile?.JoinedAt is not null && memberProfile.JoinedAt.Value < registeredAt)
{
registeredAt = memberProfile.JoinedAt.Value;
}
var latestName = customerOrders
.Select(item => item.CustomerName)
.FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? string.Empty;
var name = ResolveCustomerName(group.Key, latestName, memberProfile);
var tags = BuildTags(totalAmount, averageAmount, orderCount, registeredAt, lastOrderAt, nowUtc);
var member = BuildMemberSummary(memberProfile);
return new CustomerAggregate
{
CustomerKey = group.Key,
Name = name,
PhoneMasked = MaskPhone(group.Key),
AvatarText = ResolveAvatarText(name, group.Key),
AvatarColor = ResolveAvatarColor(group.Key),
RegisteredAt = registeredAt,
FirstOrderAt = firstOrderAt,
LastOrderAt = lastOrderAt,
Source = member.IsMember ? "会员中心" : "小程序",
TotalAmount = totalAmount,
AverageAmount = averageAmount,
OrderCount = orderCount,
Member = member,
Tags = tags,
IsDimmed = tags.Any(tag => tag.Code is TagDormant or TagChurn),
Orders = customerOrders
};
})
.OrderByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.ToList();
}
private static string ResolvePeakSlot(int hour)
{
return hour switch
{
>= 6 and < 10 => "06:00-10:00",
>= 10 and < 14 => "10:00-14:00",
>= 14 and < 17 => "14:00-17:00",
>= 17 and < 21 => "17:00-21:00",
_ => "21:00-06:00"
};
}
private static async Task<Dictionary<string, MemberProfileSnapshot>> LoadMemberProfileLookupAsync(
IDapperExecutor dapperExecutor,
long tenantId,
IReadOnlySet<string> customerKeys,
CancellationToken cancellationToken)
{
if (customerKeys.Count == 0)
{
return [];
}
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
await using var command = CreateCommand(
connection,
"""
select
profile."Mobile",
profile."Nickname",
profile."JoinedAt",
profile."PointsBalance",
profile."GrowthValue",
tier."Name" as "TierName"
from public.member_profiles profile
left join public.member_tiers tier
on tier."Id" = profile."MemberTierId"
and tier."TenantId" = profile."TenantId"
and tier."DeletedAt" is null
where profile."DeletedAt" is null
and profile."TenantId" = @tenantId;
""",
[
("tenantId", tenantId)
]);
await using var reader = await command.ExecuteReaderAsync(token);
var mobileOrdinal = reader.GetOrdinal("Mobile");
var nicknameOrdinal = reader.GetOrdinal("Nickname");
var joinedAtOrdinal = reader.GetOrdinal("JoinedAt");
var tierNameOrdinal = reader.GetOrdinal("TierName");
var pointsBalanceOrdinal = reader.GetOrdinal("PointsBalance");
var growthValueOrdinal = reader.GetOrdinal("GrowthValue");
var result = new Dictionary<string, MemberProfileSnapshot>(StringComparer.Ordinal);
while (await reader.ReadAsync(token))
{
var mobile = reader.IsDBNull(mobileOrdinal) ? string.Empty : reader.GetString(mobileOrdinal);
var normalizedPhone = NormalizePhone(mobile);
if (string.IsNullOrWhiteSpace(normalizedPhone) || !customerKeys.Contains(normalizedPhone))
{
continue;
}
var snapshot = new MemberProfileSnapshot
{
Nickname = reader.IsDBNull(nicknameOrdinal) ? string.Empty : reader.GetString(nicknameOrdinal),
JoinedAt = reader.IsDBNull(joinedAtOrdinal) ? null : reader.GetDateTime(joinedAtOrdinal),
TierName = reader.IsDBNull(tierNameOrdinal) ? string.Empty : reader.GetString(tierNameOrdinal),
PointsBalance = reader.IsDBNull(pointsBalanceOrdinal) ? 0 : reader.GetInt32(pointsBalanceOrdinal),
GrowthValue = reader.IsDBNull(growthValueOrdinal) ? 0 : reader.GetInt32(growthValueOrdinal)
};
result[normalizedPhone] = snapshot;
}
return result;
},
cancellationToken);
}
private static DbCommand CreateCommand(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
return (DbCommand)command;
}
private sealed class ProductCounter
{
internal int Count { get; set; }
internal long ProductId { get; init; }
internal string ProductName { get; init; } = string.Empty;
}
}
internal sealed class CustomerAggregate
{
internal string AvatarColor { get; init; } = string.Empty;
internal string AvatarText { get; init; } = string.Empty;
internal decimal AverageAmount { get; init; }
internal string CustomerKey { get; init; } = string.Empty;
internal DateTime FirstOrderAt { get; init; }
internal bool IsDimmed { get; init; }
internal DateTime LastOrderAt { get; init; }
internal CustomerMemberSummaryDto Member { get; init; } = new();
internal string Name { get; init; } = string.Empty;
internal int OrderCount { get; init; }
internal IReadOnlyList<CustomerOrderSnapshot> Orders { get; init; } = [];
internal string PhoneMasked { get; init; } = string.Empty;
internal DateTime RegisteredAt { get; init; }
internal string Source { get; init; } = string.Empty;
internal IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
internal decimal TotalAmount { get; init; }
}
internal sealed class CustomerOrderSnapshot
{
internal decimal Amount { get; init; }
internal string CustomerKey { get; init; } = string.Empty;
internal string CustomerName { get; init; } = string.Empty;
internal DeliveryType DeliveryType { get; init; }
internal long OrderId { get; init; }
internal string OrderNo { get; init; } = string.Empty;
internal DateTime OrderedAt { get; init; }
internal OrderStatus Status { get; init; }
internal long StoreId { get; init; }
}
internal sealed class MemberProfileSnapshot
{
internal int GrowthValue { get; init; }
internal DateTime? JoinedAt { get; init; }
internal string Nickname { get; init; } = string.Empty;
internal int PointsBalance { get; init; }
internal string TierName { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,107 @@
using System.Globalization;
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户列表 CSV 导出处理器。
/// </summary>
public sealed class ExportCustomerCsvQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<ExportCustomerCsvQuery, CustomerExportDto>
{
/// <inheritdoc />
public async Task<CustomerExportDto> Handle(
ExportCustomerCsvQuery request,
CancellationToken cancellationToken)
{
if (request.VisibleStoreIds.Count == 0)
{
return BuildExport([], 0);
}
// 1. 加载聚合并应用筛选
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
customers,
request.Keyword,
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
request.RegisterPeriodDays,
nowUtc)
.OrderByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.ToList();
return BuildExport(filteredCustomers, filteredCustomers.Count);
}
private static CustomerExportDto BuildExport(
IReadOnlyList<CustomerAggregate> customers,
int totalCount)
{
var csv = BuildCsv(customers);
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
return new CustomerExportDto
{
FileName = $"客户列表_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = totalCount
};
}
private static string BuildCsv(IReadOnlyList<CustomerAggregate> customers)
{
var sb = new StringBuilder();
sb.AppendLine("客户名称,手机号,下单次数,累计消费,客单价,最近下单时间,注册时间,客户标签");
foreach (var customer in customers)
{
var tags = customer.Tags.Count == 0
? string.Empty
: string.Join('、', customer.Tags.Select(item => item.Label));
var row = new[]
{
Escape(customer.Name),
Escape(customer.PhoneMasked),
Escape(customer.OrderCount.ToString(CultureInfo.InvariantCulture)),
Escape(customer.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
Escape(customer.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
Escape(customer.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
Escape(customer.RegisteredAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
Escape(tags)
};
sb.AppendLine(string.Join(',', row));
}
return sb.ToString();
}
private static string Escape(string input)
{
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
{
return input;
}
return $"\"{input.Replace("\"", "\"\"")}\"";
}
}

View File

@@ -0,0 +1,107 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户详情查询处理器。
/// </summary>
public sealed class GetCustomerDetailQueryHandler(
IOrderRepository orderRepository,
IProductRepository productRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerDetailQuery, CustomerDetailDto?>
{
/// <inheritdoc />
public async Task<CustomerDetailDto?> Handle(
GetCustomerDetailQuery request,
CancellationToken cancellationToken)
{
// 1. 参数与可见门店校验
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey) || request.VisibleStoreIds.Count == 0)
{
return null;
}
// 2. 加载客户聚合并定位目标客户
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var customer = customers.FirstOrDefault(item =>
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
if (customer is null)
{
return null;
}
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId <= 0)
{
return null;
}
// 3. 加载订单明细并计算画像细节
var orderIds = customer.Orders
.Select(item => item.OrderId)
.ToList();
var itemsLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
var topProducts = CustomerAnalyticsSupport.BuildTopProducts(itemsLookup, orderIds, 5);
var preferredCategories = await CustomerAnalyticsSupport.ResolvePreferredCategoriesAsync(
productRepository,
tenantId,
itemsLookup,
orderIds,
cancellationToken);
var preferredDelivery = CustomerAnalyticsSupport.ResolvePreferredDelivery(customer.Orders);
var preferredPaymentMethod = await CustomerAnalyticsSupport.ResolvePreferredPaymentMethodAsync(
orderRepository,
tenantId,
customer.Orders,
cancellationToken);
var preferredOrderPeaks = CustomerAnalyticsSupport.ResolvePreferredOrderPeaks(customer.Orders);
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemsLookup, 3);
var trend = CustomerAnalyticsSupport.BuildMonthlyTrend(customer.Orders, DateTime.UtcNow, 6);
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
Math.Max(0, customer.OrderCount - 1),
customer.OrderCount);
return new CustomerDetailDto
{
CustomerKey = customer.CustomerKey,
Name = customer.Name,
PhoneMasked = customer.PhoneMasked,
RegisteredAt = customer.RegisteredAt,
FirstOrderAt = customer.FirstOrderAt,
Source = customer.Source,
Tags = customer.Tags,
Member = customer.Member,
TotalOrders = customer.OrderCount,
TotalAmount = customer.TotalAmount,
AverageAmount = customer.AverageAmount,
RepurchaseRatePercent = repurchaseRatePercent,
Preference = new CustomerPreferenceDto
{
PreferredCategories = preferredCategories,
PreferredOrderPeaks = preferredOrderPeaks,
PreferredDelivery = preferredDelivery,
PreferredPaymentMethod = preferredPaymentMethod,
AverageDeliveryDistance = string.Empty
},
TopProducts = topProducts,
Trend = trend,
RecentOrders = recentOrders
};
}
}

View File

@@ -0,0 +1,85 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户列表统计查询处理器。
/// </summary>
public sealed class GetCustomerListStatsQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerListStatsQuery, CustomerListStatsDto>
{
/// <inheritdoc />
public async Task<CustomerListStatsDto> Handle(
GetCustomerListStatsQuery request,
CancellationToken cancellationToken)
{
// 1. 可见门店为空时直接返回空统计
if (request.VisibleStoreIds.Count == 0)
{
return new CustomerListStatsDto();
}
// 2. 加载客户聚合并应用筛选
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
customers,
request.Keyword,
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
request.RegisterPeriodDays,
nowUtc);
// 3. 计算统计指标
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var nextMonthStart = monthStart.AddMonths(1);
var previousMonthStart = monthStart.AddMonths(-1);
var totalCustomers = filteredCustomers.Count;
var monthlyNewCustomers = filteredCustomers.Count(item =>
item.RegisteredAt >= monthStart &&
item.RegisteredAt < nextMonthStart);
var previousMonthlyNewCustomers = filteredCustomers.Count(item =>
item.RegisteredAt >= previousMonthStart &&
item.RegisteredAt < monthStart);
var activeCustomers = filteredCustomers.Count(item =>
item.LastOrderAt >= nowUtc.AddDays(-30));
var recentOrders = filteredCustomers
.SelectMany(item => item.Orders)
.Where(item => item.OrderedAt >= nowUtc.AddDays(-30))
.ToList();
var averageAmountLast30Days = recentOrders.Count == 0
? 0
: decimal.Round(
recentOrders.Sum(item => item.Amount) / recentOrders.Count,
2,
MidpointRounding.AwayFromZero);
return new CustomerListStatsDto
{
TotalCustomers = totalCustomers,
MonthlyNewCustomers = monthlyNewCustomers,
MonthlyGrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(
monthlyNewCustomers,
previousMonthlyNewCustomers),
ActiveCustomers = activeCustomers,
AverageAmountLast30Days = averageAmountLast30Days
};
}
}

View File

@@ -0,0 +1,109 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户画像查询处理器。
/// </summary>
public sealed class GetCustomerProfileQueryHandler(
IOrderRepository orderRepository,
IProductRepository productRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerProfileQuery, CustomerProfileDto?>
{
/// <inheritdoc />
public async Task<CustomerProfileDto?> Handle(
GetCustomerProfileQuery request,
CancellationToken cancellationToken)
{
// 1. 参数与可见门店校验
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey) || request.VisibleStoreIds.Count == 0)
{
return null;
}
// 2. 加载客户聚合并定位目标客户
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var customer = customers.FirstOrDefault(item =>
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
if (customer is null)
{
return null;
}
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId <= 0)
{
return null;
}
// 3. 加载订单明细并计算画像数据
var orderIds = customer.Orders
.Select(item => item.OrderId)
.ToList();
var itemsLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
var topProducts = CustomerAnalyticsSupport.BuildTopProducts(itemsLookup, orderIds, 5);
var preferredCategories = await CustomerAnalyticsSupport.ResolvePreferredCategoriesAsync(
productRepository,
tenantId,
itemsLookup,
orderIds,
cancellationToken);
var preferredDelivery = CustomerAnalyticsSupport.ResolvePreferredDelivery(customer.Orders);
var preferredPaymentMethod = await CustomerAnalyticsSupport.ResolvePreferredPaymentMethodAsync(
orderRepository,
tenantId,
customer.Orders,
cancellationToken);
var preferredOrderPeaks = CustomerAnalyticsSupport.ResolvePreferredOrderPeaks(customer.Orders);
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemsLookup, 5);
var trend = CustomerAnalyticsSupport.BuildMonthlyTrend(customer.Orders, DateTime.UtcNow, 12);
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
Math.Max(0, customer.OrderCount - 1),
customer.OrderCount);
var averageOrderIntervalDays = CustomerAnalyticsSupport.CalculateAverageIntervalDays(customer.Orders);
return new CustomerProfileDto
{
CustomerKey = customer.CustomerKey,
Name = customer.Name,
PhoneMasked = customer.PhoneMasked,
RegisteredAt = customer.RegisteredAt,
FirstOrderAt = customer.FirstOrderAt,
Source = customer.Source,
Tags = customer.Tags,
Member = customer.Member,
TotalOrders = customer.OrderCount,
TotalAmount = customer.TotalAmount,
AverageAmount = customer.AverageAmount,
RepurchaseRatePercent = repurchaseRatePercent,
AverageOrderIntervalDays = averageOrderIntervalDays,
Preference = new CustomerPreferenceDto
{
PreferredCategories = preferredCategories,
PreferredOrderPeaks = preferredOrderPeaks,
PreferredDelivery = preferredDelivery,
PreferredPaymentMethod = preferredPaymentMethod,
AverageDeliveryDistance = string.Empty
},
TopProducts = topProducts,
Trend = trend,
RecentOrders = recentOrders
};
}
}

View File

@@ -0,0 +1,80 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户列表查询处理器。
/// </summary>
public sealed class SearchCustomerListQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<SearchCustomerListQuery, PagedResult<CustomerListItemDto>>
{
/// <inheritdoc />
public async Task<PagedResult<CustomerListItemDto>> Handle(
SearchCustomerListQuery request,
CancellationToken cancellationToken)
{
// 1. 规范化分页参数
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
if (request.VisibleStoreIds.Count == 0)
{
return new PagedResult<CustomerListItemDto>([], page, pageSize, 0);
}
// 2. 加载客户聚合并应用筛选
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var filteredCustomers = CustomerAnalyticsSupport.ApplyFilters(
customers,
request.Keyword,
CustomerAnalyticsSupport.NormalizeTag(request.Tag),
CustomerAnalyticsSupport.NormalizeOrderCountRange(request.OrderCountRange),
request.RegisterPeriodDays,
nowUtc)
.OrderByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.ToList();
// 3. 执行分页与列表映射
var totalCount = filteredCustomers.Count;
var maxOrderCount = Math.Max(1, filteredCustomers.Select(item => item.OrderCount).DefaultIfEmpty(1).Max());
var items = filteredCustomers
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(item => new CustomerListItemDto
{
CustomerKey = item.CustomerKey,
Name = item.Name,
PhoneMasked = item.PhoneMasked,
AvatarText = item.AvatarText,
AvatarColor = item.AvatarColor,
OrderCount = item.OrderCount,
OrderCountBarPercent = Math.Max(4, (int)Math.Round(item.OrderCount * 100d / maxOrderCount, MidpointRounding.AwayFromZero)),
TotalAmount = item.TotalAmount,
AverageAmount = item.AverageAmount,
RegisteredAt = item.RegisteredAt,
LastOrderAt = item.LastOrderAt,
Tags = item.Tags,
IsDimmed = item.IsDimmed
})
.ToList();
return new PagedResult<CustomerListItemDto>(items, page, pageSize, totalCount);
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户列表 CSV 导出查询。
/// </summary>
public sealed class ExportCustomerCsvQuery : IRequest<CustomerExportDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 客户标签筛选。
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// 下单次数区间。
/// </summary>
public string? OrderCountRange { get; init; }
/// <summary>
/// 注册周期天数7/30/90
/// </summary>
public int? RegisterPeriodDays { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户详情查询。
/// </summary>
public sealed class GetCustomerDetailQuery : IRequest<CustomerDetailDto?>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户列表统计查询。
/// </summary>
public sealed class GetCustomerListStatsQuery : IRequest<CustomerListStatsDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 客户标签筛选。
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// 下单次数区间。
/// </summary>
public string? OrderCountRange { get; init; }
/// <summary>
/// 注册周期天数7/30/90
/// </summary>
public int? RegisterPeriodDays { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户画像查询。
/// </summary>
public sealed class GetCustomerProfileQuery : IRequest<CustomerProfileDto?>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户列表查询。
/// </summary>
public sealed class SearchCustomerListQuery : IRequest<PagedResult<CustomerListItemDto>>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 客户标签筛选。
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// 下单次数区间。
/// </summary>
public string? OrderCountRange { get; init; }
/// <summary>
/// 注册周期天数7/30/90
/// </summary>
public int? RegisterPeriodDays { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}