From 26afffd874b87be2b2e577f861058cbdbcfa2776 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Tue, 3 Mar 2026 16:45:39 +0800
Subject: [PATCH] feat(customer): add customer analysis query APIs
---
.../Customer/CustomerAnalysisContracts.cs | 521 +++++++++++++++++
.../Controllers/CustomerAnalysisController.cs | 535 ++++++++++++++++++
.../App/Customers/Dto/CustomerAnalysisDtos.cs | 431 ++++++++++++++
.../CustomerAnalysisSegmentSupport.cs | 478 ++++++++++++++++
.../ExportCustomerAnalysisCsvQueryHandler.cs | 187 ++++++
...GetCustomerAnalysisOverviewQueryHandler.cs | 94 +++
...CustomerAnalysisSegmentListQueryHandler.cs | 102 ++++
.../GetCustomerMemberDetailQueryHandler.cs | 77 +++
.../Queries/ExportCustomerAnalysisCsvQuery.cs | 25 +
.../GetCustomerAnalysisOverviewQuery.cs | 25 +
.../GetCustomerAnalysisSegmentListQuery.cs | 45 ++
.../Queries/GetCustomerMemberDetailQuery.cs | 20 +
12 files changed, 2540 insertions(+)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs
new file mode 100644
index 0000000..e6de396
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerAnalysisContracts.cs
@@ -0,0 +1,521 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Customer;
+
+///
+/// 客户分析总览请求。
+///
+public sealed class CustomerAnalysisOverviewRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 统计周期(7d/30d/90d/365d)。
+ ///
+ public string? Period { get; set; }
+}
+
+///
+/// 客群明细筛选请求。
+///
+public class CustomerAnalysisSegmentFilterRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 统计周期(7d/30d/90d/365d)。
+ ///
+ public string? Period { get; set; }
+
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; set; } = "all";
+
+ ///
+ /// 关键词(姓名/手机号)。
+ ///
+ public string? Keyword { get; set; }
+}
+
+///
+/// 客群明细分页请求。
+///
+public sealed class CustomerAnalysisSegmentListRequest : CustomerAnalysisSegmentFilterRequest
+{
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 会员详情请求。
+///
+public sealed class CustomerMemberDetailRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+}
+
+///
+/// 客户分析导出请求。
+///
+public sealed class CustomerAnalysisExportRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 统计周期(7d/30d/90d/365d)。
+ ///
+ public string? Period { get; set; }
+}
+
+///
+/// 客户分析趋势点响应。
+///
+public sealed class CustomerAnalysisTrendPointResponse
+{
+ ///
+ /// 维度标签。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 数量值。
+ ///
+ public int Value { get; set; }
+}
+
+///
+/// 新老客构成项响应。
+///
+public sealed class CustomerAnalysisCompositionItemResponse
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; set; } = string.Empty;
+
+ ///
+ /// 分群名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 人数。
+ ///
+ public int Count { get; set; }
+
+ ///
+ /// 占比(百分比)。
+ ///
+ public decimal Percent { get; set; }
+
+ ///
+ /// 色调。
+ ///
+ public string Tone { get; set; } = "blue";
+}
+
+///
+/// 客单价分布项响应。
+///
+public sealed class CustomerAnalysisAmountDistributionItemResponse
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; set; } = string.Empty;
+
+ ///
+ /// 区间标签。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 人数。
+ ///
+ public int Count { get; set; }
+
+ ///
+ /// 占比(百分比)。
+ ///
+ public decimal Percent { get; set; }
+}
+
+///
+/// RFM 分层单元响应。
+///
+public sealed class CustomerAnalysisRfmCellResponse
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; set; } = string.Empty;
+
+ ///
+ /// 标签。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 人数。
+ ///
+ public int Count { get; set; }
+
+ ///
+ /// 温度(hot/warm/cool/cold)。
+ ///
+ public string Tone { get; set; } = "cold";
+}
+
+///
+/// RFM 分层行响应。
+///
+public sealed class CustomerAnalysisRfmRowResponse
+{
+ ///
+ /// 行标签。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 单元格集合。
+ ///
+ public List Cells { get; set; } = [];
+}
+
+///
+/// 高价值客户响应。
+///
+public sealed class CustomerAnalysisTopCustomerResponse
+{
+ ///
+ /// 排名。
+ ///
+ public int Rank { get; set; }
+
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; set; } = string.Empty;
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 下单次数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; set; }
+
+ ///
+ /// 最近下单时间(yyyy-MM-dd)。
+ ///
+ public string LastOrderAt { get; set; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public List Tags { get; set; } = [];
+}
+
+///
+/// 客户分析总览响应。
+///
+public sealed class CustomerAnalysisOverviewResponse
+{
+ ///
+ /// 统计周期编码。
+ ///
+ public string PeriodCode { get; set; } = "30d";
+
+ ///
+ /// 统计周期天数。
+ ///
+ public int PeriodDays { get; set; } = 30;
+
+ ///
+ /// 客户总数。
+ ///
+ public int TotalCustomers { get; set; }
+
+ ///
+ /// 周期新增客户数。
+ ///
+ public int NewCustomers { get; set; }
+
+ ///
+ /// 新增较上一周期增长百分比。
+ ///
+ public decimal GrowthRatePercent { get; set; }
+
+ ///
+ /// 周期内日均新增客户。
+ ///
+ public decimal NewCustomersDailyAverage { get; set; }
+
+ ///
+ /// 活跃客户数。
+ ///
+ public int ActiveCustomers { get; set; }
+
+ ///
+ /// 活跃率(百分比)。
+ ///
+ public decimal ActiveRatePercent { get; set; }
+
+ ///
+ /// 平均客户价值。
+ ///
+ public decimal AverageLifetimeValue { get; set; }
+
+ ///
+ /// 客户增长趋势。
+ ///
+ public List GrowthTrend { get; set; } = [];
+
+ ///
+ /// 新老客占比。
+ ///
+ public List Composition { get; set; } = [];
+
+ ///
+ /// 客单价分布。
+ ///
+ public List AmountDistribution { get; set; } = [];
+
+ ///
+ /// RFM 分层。
+ ///
+ public List RfmRows { get; set; } = [];
+
+ ///
+ /// 高价值客户 Top10。
+ ///
+ public List TopCustomers { get; set; } = [];
+}
+
+///
+/// 客群明细行响应。
+///
+public sealed class CustomerAnalysisSegmentListItemResponse
+{
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; set; } = string.Empty;
+
+ ///
+ /// 头像文案。
+ ///
+ public string AvatarText { get; set; } = string.Empty;
+
+ ///
+ /// 头像颜色。
+ ///
+ public string AvatarColor { get; set; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public List Tags { get; set; } = [];
+
+ ///
+ /// 是否会员。
+ ///
+ public bool IsMember { get; set; }
+
+ ///
+ /// 会员等级。
+ ///
+ public string MemberTierName { get; set; } = string.Empty;
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 下单次数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; set; }
+
+ ///
+ /// 注册时间(yyyy-MM-dd)。
+ ///
+ public string RegisteredAt { get; set; } = string.Empty;
+
+ ///
+ /// 最近下单时间(yyyy-MM-dd)。
+ ///
+ public string LastOrderAt { get; set; } = string.Empty;
+
+ ///
+ /// 是否弱化显示。
+ ///
+ public bool IsDimmed { get; set; }
+}
+
+///
+/// 客群明细分页响应。
+///
+public sealed class CustomerAnalysisSegmentListResultResponse
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; set; } = string.Empty;
+
+ ///
+ /// 分群标题。
+ ///
+ public string SegmentTitle { get; set; } = string.Empty;
+
+ ///
+ /// 分群说明。
+ ///
+ public string SegmentDescription { get; set; } = string.Empty;
+
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 当前页。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总记录数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 会员详情响应。
+///
+public sealed class CustomerMemberDetailResponse
+{
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; set; } = string.Empty;
+
+ ///
+ /// 来源。
+ ///
+ public string Source { get; set; } = string.Empty;
+
+ ///
+ /// 注册时间(yyyy-MM-dd)。
+ ///
+ public string RegisteredAt { get; set; } = string.Empty;
+
+ ///
+ /// 最近下单时间(yyyy-MM-dd)。
+ ///
+ public string LastOrderAt { get; set; } = string.Empty;
+
+ ///
+ /// 会员摘要。
+ ///
+ public CustomerMemberSummaryResponse Member { get; set; } = new();
+
+ ///
+ /// 客户标签。
+ ///
+ public List Tags { get; set; } = [];
+
+ ///
+ /// 累计下单次数。
+ ///
+ public int TotalOrders { get; set; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; set; }
+
+ ///
+ /// 复购率(百分比)。
+ ///
+ public decimal RepurchaseRatePercent { get; set; }
+
+ ///
+ /// 最近订单。
+ ///
+ public List RecentOrders { get; set; } = [];
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs
new file mode 100644
index 0000000..0d63e2b
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerAnalysisController.cs
@@ -0,0 +1,535 @@
+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;
+
+///
+/// 客户分析。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/customer/analysis")]
+public sealed class CustomerAnalysisController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService)
+ : BaseApiController
+{
+ private const string ViewPermission = "tenant:customer:analysis:view";
+
+ ///
+ /// 获取客户分析总览。
+ ///
+ [HttpGet("overview")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Overview(
+ [FromQuery] CustomerAnalysisOverviewRequest request,
+ CancellationToken cancellationToken)
+ {
+ var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
+ var (periodCode, periodDays) = ParsePeriod(request.Period);
+
+ var result = await mediator.Send(new GetCustomerAnalysisOverviewQuery
+ {
+ VisibleStoreIds = visibleStoreIds,
+ PeriodCode = periodCode,
+ PeriodDays = periodDays
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapOverview(result));
+ }
+
+ ///
+ /// 获取客群明细。
+ ///
+ [HttpGet("segment/list")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> SegmentList(
+ [FromQuery] CustomerAnalysisSegmentListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
+ var (periodCode, periodDays) = ParsePeriod(request.Period);
+
+ var result = await mediator.Send(new GetCustomerAnalysisSegmentListQuery
+ {
+ VisibleStoreIds = visibleStoreIds,
+ PeriodCode = periodCode,
+ PeriodDays = periodDays,
+ SegmentCode = request.SegmentCode,
+ Keyword = request.Keyword,
+ Page = Math.Max(1, request.Page),
+ PageSize = Math.Clamp(request.PageSize, 1, 200)
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapSegmentList(result));
+ }
+
+ ///
+ /// 获取客户详情(分析页二级抽屉)。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] CustomerDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ var customerKey = NormalizePhone(request.CustomerKey);
+ if (string.IsNullOrWhiteSpace(customerKey))
+ {
+ return ApiResponse.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.Error(ErrorCodes.NotFound, "客户不存在");
+ }
+
+ return ApiResponse.Ok(MapDetail(result));
+ }
+
+ ///
+ /// 获取客户完整画像(分析页二级抽屉)。
+ ///
+ [HttpGet("profile")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Profile(
+ [FromQuery] CustomerProfileRequest request,
+ CancellationToken cancellationToken)
+ {
+ var customerKey = NormalizePhone(request.CustomerKey);
+ if (string.IsNullOrWhiteSpace(customerKey))
+ {
+ return ApiResponse.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.Error(ErrorCodes.NotFound, "客户不存在");
+ }
+
+ return ApiResponse.Ok(MapProfile(result));
+ }
+
+ ///
+ /// 获取会员详情。
+ ///
+ [HttpGet("member/detail")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> MemberDetail(
+ [FromQuery] CustomerMemberDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ var customerKey = NormalizePhone(request.CustomerKey);
+ if (string.IsNullOrWhiteSpace(customerKey))
+ {
+ return ApiResponse.Error(ErrorCodes.BadRequest, "customerKey 非法");
+ }
+
+ var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
+
+ var result = await mediator.Send(new GetCustomerMemberDetailQuery
+ {
+ VisibleStoreIds = visibleStoreIds,
+ CustomerKey = customerKey
+ }, cancellationToken);
+
+ if (result is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "客户不存在");
+ }
+
+ return ApiResponse.Ok(MapMemberDetail(result));
+ }
+
+ ///
+ /// 导出客户分析报表。
+ ///
+ [HttpGet("export")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Export(
+ [FromQuery] CustomerAnalysisExportRequest request,
+ CancellationToken cancellationToken)
+ {
+ var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
+ var (periodCode, periodDays) = ParsePeriod(request.Period);
+
+ var result = await mediator.Send(new ExportCustomerAnalysisCsvQuery
+ {
+ VisibleStoreIds = visibleStoreIds,
+ PeriodCode = periodCode,
+ PeriodDays = periodDays
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new CustomerExportResponse
+ {
+ FileName = result.FileName,
+ FileContentBase64 = result.FileContentBase64,
+ TotalCount = result.TotalCount
+ });
+ }
+
+ private async Task> 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 (string PeriodCode, int PeriodDays) ParsePeriod(string? period)
+ {
+ var normalized = (period ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return ("30d", 30);
+ }
+
+ return normalized switch
+ {
+ "7" or "7d" => ("7d", 7),
+ "30" or "30d" => ("30d", 30),
+ "90" or "90d" => ("90d", 90),
+ "365" or "365d" or "1y" or "1year" => ("365d", 365),
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "period 参数不合法")
+ };
+ }
+
+ 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 CustomerAnalysisOverviewResponse MapOverview(CustomerAnalysisOverviewDto source)
+ {
+ return new CustomerAnalysisOverviewResponse
+ {
+ PeriodCode = source.PeriodCode,
+ PeriodDays = source.PeriodDays,
+ TotalCustomers = source.TotalCustomers,
+ NewCustomers = source.NewCustomers,
+ GrowthRatePercent = source.GrowthRatePercent,
+ NewCustomersDailyAverage = source.NewCustomersDailyAverage,
+ ActiveCustomers = source.ActiveCustomers,
+ ActiveRatePercent = source.ActiveRatePercent,
+ AverageLifetimeValue = source.AverageLifetimeValue,
+ GrowthTrend = source.GrowthTrend.Select(MapTrendPoint).ToList(),
+ Composition = source.Composition.Select(MapCompositionItem).ToList(),
+ AmountDistribution = source.AmountDistribution.Select(MapAmountDistributionItem).ToList(),
+ RfmRows = source.RfmRows.Select(MapRfmRow).ToList(),
+ TopCustomers = source.TopCustomers.Select(MapTopCustomer).ToList()
+ };
+ }
+
+ private static CustomerAnalysisTrendPointResponse MapTrendPoint(CustomerAnalysisTrendPointDto source)
+ {
+ return new CustomerAnalysisTrendPointResponse
+ {
+ Label = source.Label,
+ Value = source.Value
+ };
+ }
+
+ private static CustomerAnalysisCompositionItemResponse MapCompositionItem(CustomerAnalysisCompositionItemDto source)
+ {
+ return new CustomerAnalysisCompositionItemResponse
+ {
+ SegmentCode = source.SegmentCode,
+ Label = source.Label,
+ Count = source.Count,
+ Percent = source.Percent,
+ Tone = source.Tone
+ };
+ }
+
+ private static CustomerAnalysisAmountDistributionItemResponse MapAmountDistributionItem(
+ CustomerAnalysisAmountDistributionItemDto source)
+ {
+ return new CustomerAnalysisAmountDistributionItemResponse
+ {
+ SegmentCode = source.SegmentCode,
+ Label = source.Label,
+ Count = source.Count,
+ Percent = source.Percent
+ };
+ }
+
+ private static CustomerAnalysisRfmRowResponse MapRfmRow(CustomerAnalysisRfmRowDto source)
+ {
+ return new CustomerAnalysisRfmRowResponse
+ {
+ Label = source.Label,
+ Cells = source.Cells.Select(MapRfmCell).ToList()
+ };
+ }
+
+ private static CustomerAnalysisRfmCellResponse MapRfmCell(CustomerAnalysisRfmCellDto source)
+ {
+ return new CustomerAnalysisRfmCellResponse
+ {
+ SegmentCode = source.SegmentCode,
+ Label = source.Label,
+ Count = source.Count,
+ Tone = source.Tone
+ };
+ }
+
+ private static CustomerAnalysisTopCustomerResponse MapTopCustomer(CustomerAnalysisTopCustomerDto source)
+ {
+ return new CustomerAnalysisTopCustomerResponse
+ {
+ Rank = source.Rank,
+ CustomerKey = source.CustomerKey,
+ Name = source.Name,
+ PhoneMasked = source.PhoneMasked,
+ TotalAmount = source.TotalAmount,
+ OrderCount = source.OrderCount,
+ AverageAmount = source.AverageAmount,
+ LastOrderAt = ToDateOnly(source.LastOrderAt),
+ Tags = source.Tags.Select(MapTag).ToList()
+ };
+ }
+
+ 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 CustomerAnalysisSegmentListResultResponse MapSegmentList(CustomerAnalysisSegmentListResultDto source)
+ {
+ return new CustomerAnalysisSegmentListResultResponse
+ {
+ SegmentCode = source.SegmentCode,
+ SegmentTitle = source.SegmentTitle,
+ SegmentDescription = source.SegmentDescription,
+ Items = source.Items.Select(MapSegmentListItem).ToList(),
+ Page = source.Page,
+ PageSize = source.PageSize,
+ TotalCount = source.TotalCount
+ };
+ }
+
+ private static CustomerAnalysisSegmentListItemResponse MapSegmentListItem(CustomerAnalysisSegmentListItemDto source)
+ {
+ return new CustomerAnalysisSegmentListItemResponse
+ {
+ CustomerKey = source.CustomerKey,
+ Name = source.Name,
+ PhoneMasked = source.PhoneMasked,
+ AvatarText = source.AvatarText,
+ AvatarColor = source.AvatarColor,
+ Tags = source.Tags.Select(MapTag).ToList(),
+ IsMember = source.IsMember,
+ MemberTierName = source.MemberTierName,
+ TotalAmount = source.TotalAmount,
+ OrderCount = source.OrderCount,
+ AverageAmount = source.AverageAmount,
+ RegisteredAt = ToDateOnly(source.RegisteredAt),
+ LastOrderAt = ToDateOnly(source.LastOrderAt),
+ IsDimmed = source.IsDimmed
+ };
+ }
+
+ private static CustomerMemberDetailResponse MapMemberDetail(CustomerMemberDetailDto source)
+ {
+ return new CustomerMemberDetailResponse
+ {
+ CustomerKey = source.CustomerKey,
+ Name = source.Name,
+ PhoneMasked = source.PhoneMasked,
+ Source = source.Source,
+ RegisteredAt = ToDateOnly(source.RegisteredAt),
+ LastOrderAt = ToDateOnly(source.LastOrderAt),
+ Member = MapMember(source.Member),
+ Tags = source.Tags.Select(MapTag).ToList(),
+ TotalOrders = source.TotalOrders,
+ TotalAmount = source.TotalAmount,
+ AverageAmount = source.AverageAmount,
+ RepurchaseRatePercent = source.RepurchaseRatePercent,
+ 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 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 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 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);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs
new file mode 100644
index 0000000..a9d6dea
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerAnalysisDtos.cs
@@ -0,0 +1,431 @@
+namespace TakeoutSaaS.Application.App.Customers.Dto;
+
+///
+/// 客户分析增长趋势点 DTO。
+///
+public sealed class CustomerAnalysisTrendPointDto
+{
+ ///
+ /// 维度标签。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 数量值。
+ ///
+ public int Value { get; init; }
+}
+
+///
+/// 客户分析新老客构成项 DTO。
+///
+public sealed class CustomerAnalysisCompositionItemDto
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; init; } = string.Empty;
+
+ ///
+ /// 分群名称。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 分群人数。
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// 分群占比(百分比)。
+ ///
+ public decimal Percent { get; init; }
+
+ ///
+ /// 色调(blue/green/orange/gray)。
+ ///
+ public string Tone { get; init; } = "blue";
+}
+
+///
+/// 客单价分布项 DTO。
+///
+public sealed class CustomerAnalysisAmountDistributionItemDto
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; init; } = string.Empty;
+
+ ///
+ /// 区间标签。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 人数。
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// 占比(百分比)。
+ ///
+ public decimal Percent { get; init; }
+}
+
+///
+/// RFM 分层单元 DTO。
+///
+public sealed class CustomerAnalysisRfmCellDto
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; init; } = string.Empty;
+
+ ///
+ /// 分层标签。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 人数。
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// 温度(hot/warm/cool/cold)。
+ ///
+ public string Tone { get; init; } = "cold";
+}
+
+///
+/// RFM 分层行 DTO。
+///
+public sealed class CustomerAnalysisRfmRowDto
+{
+ ///
+ /// 行标签。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 单元格集合。
+ ///
+ public IReadOnlyList Cells { get; init; } = [];
+}
+
+///
+/// 高价值客户 DTO。
+///
+public sealed class CustomerAnalysisTopCustomerDto
+{
+ ///
+ /// 排名。
+ ///
+ public int Rank { get; init; }
+
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; init; } = string.Empty;
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 下单次数。
+ ///
+ public int OrderCount { get; init; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; init; }
+
+ ///
+ /// 最近下单时间。
+ ///
+ public DateTime LastOrderAt { get; init; }
+
+ ///
+ /// 客户标签。
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+}
+
+///
+/// 客户分析总览 DTO。
+///
+public sealed class CustomerAnalysisOverviewDto
+{
+ ///
+ /// 统计周期编码。
+ ///
+ public string PeriodCode { get; init; } = "30d";
+
+ ///
+ /// 统计周期天数。
+ ///
+ public int PeriodDays { get; init; } = 30;
+
+ ///
+ /// 客户总数。
+ ///
+ public int TotalCustomers { get; init; }
+
+ ///
+ /// 周期新增客户数。
+ ///
+ public int NewCustomers { get; init; }
+
+ ///
+ /// 新增较上一周期增长百分比。
+ ///
+ public decimal GrowthRatePercent { get; init; }
+
+ ///
+ /// 周期内日均新增客户。
+ ///
+ public decimal NewCustomersDailyAverage { get; init; }
+
+ ///
+ /// 活跃客户数。
+ ///
+ public int ActiveCustomers { get; init; }
+
+ ///
+ /// 活跃率(百分比)。
+ ///
+ public decimal ActiveRatePercent { get; init; }
+
+ ///
+ /// 平均客户价值(累计消费均值)。
+ ///
+ public decimal AverageLifetimeValue { get; init; }
+
+ ///
+ /// 客户增长趋势。
+ ///
+ public IReadOnlyList GrowthTrend { get; init; } = [];
+
+ ///
+ /// 新老客占比。
+ ///
+ public IReadOnlyList Composition { get; init; } = [];
+
+ ///
+ /// 客单价分布。
+ ///
+ public IReadOnlyList AmountDistribution { get; init; } = [];
+
+ ///
+ /// RFM 分层。
+ ///
+ public IReadOnlyList RfmRows { get; init; } = [];
+
+ ///
+ /// 高价值客户 Top10。
+ ///
+ public IReadOnlyList TopCustomers { get; init; } = [];
+}
+
+///
+/// 客群明细行 DTO。
+///
+public sealed class CustomerAnalysisSegmentListItemDto
+{
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; init; } = string.Empty;
+
+ ///
+ /// 头像文案。
+ ///
+ public string AvatarText { get; init; } = string.Empty;
+
+ ///
+ /// 头像颜色。
+ ///
+ public string AvatarColor { get; init; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+
+ ///
+ /// 是否会员。
+ ///
+ public bool IsMember { get; init; }
+
+ ///
+ /// 会员等级。
+ ///
+ public string MemberTierName { get; init; } = string.Empty;
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 下单次数。
+ ///
+ public int OrderCount { get; init; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; init; }
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 最近下单时间。
+ ///
+ public DateTime LastOrderAt { get; init; }
+
+ ///
+ /// 是否弱化显示。
+ ///
+ public bool IsDimmed { get; init; }
+}
+
+///
+/// 客群明细结果 DTO。
+///
+public sealed class CustomerAnalysisSegmentListResultDto
+{
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; init; } = string.Empty;
+
+ ///
+ /// 分群标题。
+ ///
+ public string SegmentTitle { get; init; } = string.Empty;
+
+ ///
+ /// 分群说明。
+ ///
+ public string SegmentDescription { get; init; } = string.Empty;
+
+ ///
+ /// 列表项。
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+
+ ///
+ /// 当前页。
+ ///
+ public int Page { get; init; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; }
+
+ ///
+ /// 总记录数。
+ ///
+ public int TotalCount { get; init; }
+}
+
+///
+/// 会员详情 DTO。
+///
+public sealed class CustomerMemberDetailDto
+{
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; init; } = string.Empty;
+
+ ///
+ /// 来源。
+ ///
+ public string Source { get; init; } = string.Empty;
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 最近下单时间。
+ ///
+ public DateTime LastOrderAt { get; init; }
+
+ ///
+ /// 会员摘要。
+ ///
+ public CustomerMemberSummaryDto Member { get; init; } = new();
+
+ ///
+ /// 客户标签。
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+
+ ///
+ /// 累计下单次数。
+ ///
+ public int TotalOrders { get; init; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; init; }
+
+ ///
+ /// 复购率。
+ ///
+ public decimal RepurchaseRatePercent { get; init; }
+
+ ///
+ /// 最近订单。
+ ///
+ public IReadOnlyList RecentOrders { get; init; } = [];
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs
new file mode 100644
index 0000000..fd09a79
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalysisSegmentSupport.cs
@@ -0,0 +1,478 @@
+using TakeoutSaaS.Application.App.Customers.Dto;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.App.Customers.Handlers;
+
+internal static class CustomerAnalysisSegmentSupport
+{
+ internal const string SegmentAll = "all";
+ internal const string SegmentRepeatLoyal = "repeat_loyal";
+ internal const string SegmentActiveNew = "active_new";
+ internal const string SegmentActiveRecent = "active_recent";
+ internal const string SegmentDormant = "dormant";
+ internal const string SegmentChurn = "churn";
+ internal const string SegmentHighValueTop = "high_value_top";
+
+ private static readonly string[] CompositionSegmentOrder =
+ [
+ SegmentRepeatLoyal,
+ SegmentActiveNew,
+ SegmentDormant,
+ SegmentChurn
+ ];
+
+ private static readonly (string SegmentCode, string Label, string Tone)[] CompositionDefinitions =
+ [
+ (SegmentRepeatLoyal, "老客户(复购2次+)", "blue"),
+ (SegmentActiveNew, "活跃新客", "green"),
+ (SegmentDormant, "沉睡客户", "orange"),
+ (SegmentChurn, "流失客户", "gray")
+ ];
+
+ private static readonly AmountDistributionDefinition[] AmountDistributionDefinitions =
+ [
+ new("amount_0_30", "0 - 30元", 0m, 30m),
+ new("amount_30_60", "30 - 60元", 30m, 60m),
+ new("amount_60_100", "60 - 100元", 60m, 100m),
+ new("amount_100_150", "100 - 150元", 100m, 150m),
+ new("amount_150_plus", "150元以上", 150m, null)
+ ];
+
+ private static readonly string[] RfmRowLabels =
+ [
+ "近期活跃",
+ "中期沉默",
+ "长期流失"
+ ];
+
+ private static readonly string[] RfmColumnLabels =
+ [
+ "高频高额",
+ "高频低额",
+ "低频高额",
+ "低频低额"
+ ];
+
+ private static readonly string[,] RfmCellLabels =
+ {
+ { "重要价值", "潜力客户", "新客培育", "一般维护" },
+ { "重要挽留", "一般发展", "一般保持", "低优先级" },
+ { "重要召回", "即将流失", "基本流失", "已流失" }
+ };
+
+ private static readonly string[,] RfmCellTones =
+ {
+ { "hot", "warm", "warm", "cool" },
+ { "warm", "cool", "cool", "cold" },
+ { "cool", "cold", "cold", "cold" }
+ };
+
+ internal static string NormalizeSegmentCode(string? segmentCode)
+ {
+ var normalized = (segmentCode ?? string.Empty).Trim().ToLowerInvariant();
+ return string.IsNullOrWhiteSpace(normalized) ? SegmentAll : normalized;
+ }
+
+ internal static SegmentMeta ResolveSegmentMeta(string normalizedSegmentCode)
+ {
+ var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
+ if (amountDefinition is not null)
+ {
+ return new SegmentMeta(
+ normalizedSegmentCode,
+ $"客单价分布 · {amountDefinition.Label}",
+ $"筛选客单价位于 {amountDefinition.Label} 区间的客户");
+ }
+
+ if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
+ {
+ return new SegmentMeta(
+ normalizedSegmentCode,
+ $"RFM分层 · {RfmRowLabels[rowIndex]} / {RfmColumnLabels[columnIndex]}",
+ $"当前客群标签:{RfmCellLabels[rowIndex, columnIndex]}");
+ }
+
+ return normalizedSegmentCode switch
+ {
+ SegmentAll => new SegmentMeta(SegmentAll, "全部客户", "当前门店下全部客户明细"),
+ SegmentRepeatLoyal => new SegmentMeta(SegmentRepeatLoyal, "老客户(复购2次+)", "非流失/沉睡且非新客的稳定复购客户"),
+ SegmentActiveNew => new SegmentMeta(SegmentActiveNew, "活跃新客", "统计周期内注册且有消费的客户"),
+ SegmentActiveRecent => new SegmentMeta(SegmentActiveRecent, "周期活跃客户", "统计周期内发生过消费行为的客户"),
+ SegmentDormant => new SegmentMeta(SegmentDormant, "沉睡客户", "31-60天未消费的客户"),
+ SegmentChurn => new SegmentMeta(SegmentChurn, "流失客户", "超过60天未消费的客户"),
+ SegmentHighValueTop => new SegmentMeta(SegmentHighValueTop, "高价值客户", "累计消费或消费能力达到高价值阈值的客户"),
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
+ };
+ }
+
+ internal static IReadOnlyList BuildComposition(
+ IReadOnlyList customers,
+ DateTime nowUtc,
+ int periodDays)
+ {
+ if (customers.Count == 0)
+ {
+ return CompositionDefinitions
+ .Select(item => new CustomerAnalysisCompositionItemDto
+ {
+ SegmentCode = item.SegmentCode,
+ Label = item.Label,
+ Count = 0,
+ Percent = 0,
+ Tone = item.Tone
+ })
+ .ToList();
+ }
+
+ var counter = CompositionSegmentOrder.ToDictionary(item => item, _ => 0, StringComparer.Ordinal);
+ foreach (var customer in customers)
+ {
+ var segmentCode = ResolveCompositionSegmentCode(customer, nowUtc, periodDays);
+ counter[segmentCode] += 1;
+ }
+
+ return CompositionDefinitions
+ .Select(item => new CustomerAnalysisCompositionItemDto
+ {
+ SegmentCode = item.SegmentCode,
+ Label = item.Label,
+ Count = counter[item.SegmentCode],
+ Percent = CustomerAnalyticsSupport.ToRatePercent(counter[item.SegmentCode], customers.Count),
+ Tone = item.Tone
+ })
+ .ToList();
+ }
+
+ internal static IReadOnlyList BuildAmountDistribution(
+ IReadOnlyList customers)
+ {
+ if (customers.Count == 0)
+ {
+ return AmountDistributionDefinitions
+ .Select(item => new CustomerAnalysisAmountDistributionItemDto
+ {
+ SegmentCode = item.SegmentCode,
+ Label = item.Label,
+ Count = 0,
+ Percent = 0
+ })
+ .ToList();
+ }
+
+ return AmountDistributionDefinitions
+ .Select(item =>
+ {
+ var count = customers.Count(customer => MatchAmountDistribution(customer.AverageAmount, item));
+ return new CustomerAnalysisAmountDistributionItemDto
+ {
+ SegmentCode = item.SegmentCode,
+ Label = item.Label,
+ Count = count,
+ Percent = CustomerAnalyticsSupport.ToRatePercent(count, customers.Count)
+ };
+ })
+ .ToList();
+ }
+
+ internal static IReadOnlyList BuildRfmRows(
+ IReadOnlyList customers,
+ DateTime nowUtc)
+ {
+ var counters = new int[3, 4];
+ foreach (var customer in customers)
+ {
+ var rowIndex = ResolveRfmRecencyRow(customer, nowUtc);
+ var columnIndex = ResolveRfmFrequencyAmountColumn(customer);
+ counters[rowIndex, columnIndex] += 1;
+ }
+
+ var rows = new List(3);
+ for (var rowIndex = 0; rowIndex < 3; rowIndex += 1)
+ {
+ var cells = new List(4);
+ for (var columnIndex = 0; columnIndex < 4; columnIndex += 1)
+ {
+ cells.Add(new CustomerAnalysisRfmCellDto
+ {
+ SegmentCode = BuildRfmSegmentCode(rowIndex, columnIndex),
+ Label = RfmCellLabels[rowIndex, columnIndex],
+ Count = counters[rowIndex, columnIndex],
+ Tone = RfmCellTones[rowIndex, columnIndex]
+ });
+ }
+
+ rows.Add(new CustomerAnalysisRfmRowDto
+ {
+ Label = RfmRowLabels[rowIndex],
+ Cells = cells
+ });
+ }
+
+ return rows;
+ }
+
+ internal static IReadOnlyList BuildGrowthTrend(
+ IReadOnlyList customers,
+ 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);
+
+ var countLookup = customers
+ .Where(item => item.RegisteredAt >= windowStart && item.RegisteredAt < monthStart.AddMonths(1))
+ .GroupBy(item => new DateTime(item.RegisteredAt.Year, item.RegisteredAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
+ .ToDictionary(group => group.Key, group => group.Count());
+
+ var trend = new List(normalizedMonthCount);
+ for (var index = 0; index < normalizedMonthCount; index += 1)
+ {
+ var currentMonth = windowStart.AddMonths(index);
+ countLookup.TryGetValue(currentMonth, out var value);
+
+ trend.Add(new CustomerAnalysisTrendPointDto
+ {
+ Label = $"{currentMonth.Month}月",
+ Value = value
+ });
+ }
+
+ return trend;
+ }
+
+ internal static IReadOnlyList BuildTopCustomers(
+ IReadOnlyList customers,
+ int takeCount)
+ {
+ var normalizedTakeCount = Math.Clamp(takeCount, 1, 200);
+
+ return customers
+ .OrderByDescending(item => item.TotalAmount)
+ .ThenByDescending(item => item.OrderCount)
+ .ThenByDescending(item => item.LastOrderAt)
+ .ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
+ .Take(normalizedTakeCount)
+ .Select((item, index) => new CustomerAnalysisTopCustomerDto
+ {
+ Rank = index + 1,
+ CustomerKey = item.CustomerKey,
+ Name = item.Name,
+ PhoneMasked = item.PhoneMasked,
+ TotalAmount = item.TotalAmount,
+ OrderCount = item.OrderCount,
+ AverageAmount = item.AverageAmount,
+ LastOrderAt = item.LastOrderAt,
+ Tags = item.Tags
+ })
+ .ToList();
+ }
+
+ internal static IReadOnlyList FilterBySegment(
+ IReadOnlyList customers,
+ string normalizedSegmentCode,
+ DateTime nowUtc,
+ int periodDays)
+ {
+ if (normalizedSegmentCode == SegmentAll)
+ {
+ return customers;
+ }
+
+ if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
+ {
+ return customers
+ .Where(customer =>
+ ResolveRfmRecencyRow(customer, nowUtc) == rowIndex &&
+ ResolveRfmFrequencyAmountColumn(customer) == columnIndex)
+ .ToList();
+ }
+
+ var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
+ if (amountDefinition is not null)
+ {
+ return customers
+ .Where(customer => MatchAmountDistribution(customer.AverageAmount, amountDefinition))
+ .ToList();
+ }
+
+ return normalizedSegmentCode switch
+ {
+ SegmentRepeatLoyal => customers
+ .Where(customer => string.Equals(
+ ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
+ SegmentRepeatLoyal,
+ StringComparison.Ordinal))
+ .ToList(),
+ SegmentActiveNew => customers
+ .Where(customer => string.Equals(
+ ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
+ SegmentActiveNew,
+ StringComparison.Ordinal))
+ .ToList(),
+ SegmentActiveRecent => customers
+ .Where(customer => customer.LastOrderAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
+ .ToList(),
+ SegmentDormant => customers
+ .Where(customer => string.Equals(
+ ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
+ SegmentDormant,
+ StringComparison.Ordinal))
+ .ToList(),
+ SegmentChurn => customers
+ .Where(customer => string.Equals(
+ ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
+ SegmentChurn,
+ StringComparison.Ordinal))
+ .ToList(),
+ SegmentHighValueTop => customers
+ .Where(customer =>
+ customer.Tags.Any(tag => string.Equals(tag.Code, CustomerAnalyticsSupport.TagHighValue, StringComparison.Ordinal)))
+ .ToList(),
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
+ };
+ }
+
+ internal static IReadOnlyList ApplyKeyword(
+ IReadOnlyList customers,
+ string? keyword)
+ {
+ var normalizedKeyword = (keyword ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(normalizedKeyword))
+ {
+ return customers;
+ }
+
+ var keywordDigits = CustomerAnalyticsSupport.NormalizePhone(normalizedKeyword);
+ return customers
+ .Where(customer =>
+ {
+ var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
+ var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
+ customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
+ return matchedByName || matchedByPhone;
+ })
+ .ToList();
+ }
+
+ internal static bool TryParseRfmSegmentCode(
+ string normalizedSegmentCode,
+ out int rowIndex,
+ out int columnIndex)
+ {
+ rowIndex = -1;
+ columnIndex = -1;
+
+ if (!normalizedSegmentCode.StartsWith("rfm_r", StringComparison.Ordinal) ||
+ normalizedSegmentCode.Length != 8)
+ {
+ return false;
+ }
+
+ var rowChar = normalizedSegmentCode[5];
+ var separator = normalizedSegmentCode[6];
+ var columnChar = normalizedSegmentCode[7];
+
+ if (separator != 'c' || !char.IsDigit(rowChar) || !char.IsDigit(columnChar))
+ {
+ return false;
+ }
+
+ rowIndex = rowChar - '1';
+ columnIndex = columnChar - '1';
+
+ return rowIndex is >= 0 and < 3 && columnIndex is >= 0 and < 4;
+ }
+
+ internal static string BuildRfmSegmentCode(int rowIndex, int columnIndex)
+ {
+ return $"rfm_r{rowIndex + 1}c{columnIndex + 1}";
+ }
+
+ private static string ResolveCompositionSegmentCode(
+ CustomerAggregate customer,
+ DateTime nowUtc,
+ int periodDays)
+ {
+ var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
+ if (silentDays > 60)
+ {
+ return SegmentChurn;
+ }
+
+ if (silentDays > 30)
+ {
+ return SegmentDormant;
+ }
+
+ if (customer.RegisteredAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
+ {
+ return SegmentActiveNew;
+ }
+
+ return SegmentRepeatLoyal;
+ }
+
+ private static AmountDistributionDefinition? ResolveAmountDistributionDefinition(string normalizedSegmentCode)
+ {
+ return AmountDistributionDefinitions.FirstOrDefault(item =>
+ string.Equals(item.SegmentCode, normalizedSegmentCode, StringComparison.Ordinal));
+ }
+
+ private static bool MatchAmountDistribution(decimal amount, AmountDistributionDefinition definition)
+ {
+ if (amount < definition.MinInclusive)
+ {
+ return false;
+ }
+
+ if (definition.MaxExclusive.HasValue && amount >= definition.MaxExclusive.Value)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static int ResolveRfmRecencyRow(CustomerAggregate customer, DateTime nowUtc)
+ {
+ var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
+ if (silentDays <= 30)
+ {
+ return 0;
+ }
+
+ if (silentDays <= 60)
+ {
+ return 1;
+ }
+
+ return 2;
+ }
+
+ private static int ResolveRfmFrequencyAmountColumn(CustomerAggregate customer)
+ {
+ var highFrequency = customer.OrderCount >= 10;
+ var highAmount = customer.AverageAmount >= 100m;
+
+ return (highFrequency, highAmount) switch
+ {
+ (true, true) => 0,
+ (true, false) => 1,
+ (false, true) => 2,
+ _ => 3
+ };
+ }
+
+ private sealed record AmountDistributionDefinition(
+ string SegmentCode,
+ string Label,
+ decimal MinInclusive,
+ decimal? MaxExclusive);
+}
+
+internal sealed record SegmentMeta(
+ string SegmentCode,
+ string SegmentTitle,
+ string SegmentDescription);
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs
new file mode 100644
index 0000000..3fe1a64
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerAnalysisCsvQueryHandler.cs
@@ -0,0 +1,187 @@
+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;
+
+///
+/// 客户分析报表导出处理器。
+///
+public sealed class ExportCustomerAnalysisCsvQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ ExportCustomerAnalysisCsvQuery request,
+ CancellationToken cancellationToken)
+ {
+ var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
+ ? "30d"
+ : request.PeriodCode.Trim().ToLowerInvariant();
+ var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
+
+ if (request.VisibleStoreIds.Count == 0)
+ {
+ return BuildExport(
+ BuildCsv(periodCode, periodDays, [], DateTime.UtcNow),
+ 0);
+ }
+
+ var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
+ orderRepository,
+ dapperExecutor,
+ tenantProvider,
+ request.VisibleStoreIds,
+ cancellationToken);
+
+ return BuildExport(
+ BuildCsv(periodCode, periodDays, customers, DateTime.UtcNow),
+ Math.Min(10, customers.Count));
+ }
+
+ private static CustomerExportDto BuildExport(string csv, int totalCount)
+ {
+ 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(
+ string periodCode,
+ int periodDays,
+ IReadOnlyList customers,
+ DateTime nowUtc)
+ {
+ var currentStart = nowUtc.AddDays(-periodDays);
+ var previousStart = currentStart.AddDays(-periodDays);
+
+ var totalCustomers = customers.Count;
+ var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
+ var previousNewCustomers = customers.Count(item =>
+ item.RegisteredAt >= previousStart &&
+ item.RegisteredAt < currentStart);
+ var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
+ var averageLifetimeValue = totalCustomers <= 0
+ ? 0
+ : decimal.Round(
+ customers.Sum(item => item.TotalAmount) / totalCustomers,
+ 2,
+ MidpointRounding.AwayFromZero);
+
+ var composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays);
+ var amountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers);
+ var rfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc);
+ var topCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10);
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"统计周期,{Escape(ResolvePeriodLabel(periodCode, periodDays))}");
+ sb.AppendLine();
+
+ sb.AppendLine("核心指标");
+ sb.AppendLine("指标,值");
+ sb.AppendLine($"客户总数,{totalCustomers}");
+ sb.AppendLine($"周期新增,{newCustomers}");
+ sb.AppendLine($"新增增长率,{CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers):0.#}%");
+ sb.AppendLine($"周期活跃客户,{activeCustomers}");
+ sb.AppendLine($"活跃率,{CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers):0.#}%");
+ sb.AppendLine($"平均客户价值,{averageLifetimeValue.ToString("0.00", CultureInfo.InvariantCulture)}");
+ sb.AppendLine();
+
+ sb.AppendLine("新老客占比");
+ sb.AppendLine("分层,人数,占比");
+ foreach (var item in composition)
+ {
+ sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
+ }
+ sb.AppendLine();
+
+ sb.AppendLine("客单价分布");
+ sb.AppendLine("区间,人数,占比");
+ foreach (var item in amountDistribution)
+ {
+ sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
+ }
+ sb.AppendLine();
+
+ sb.AppendLine("RFM客户分层");
+ sb.AppendLine("活跃度,分群,标签,人数");
+ for (var rowIndex = 0; rowIndex < rfmRows.Count; rowIndex += 1)
+ {
+ var row = rfmRows[rowIndex];
+ for (var columnIndex = 0; columnIndex < row.Cells.Count; columnIndex += 1)
+ {
+ var cell = row.Cells[columnIndex];
+ sb.AppendLine($"{Escape(row.Label)},{Escape(ResolveRfmColumnLabel(columnIndex))},{Escape(cell.Label)},{cell.Count}");
+ }
+ }
+ sb.AppendLine();
+
+ sb.AppendLine("高价值客户TOP10");
+ sb.AppendLine("排名,客户,手机号,累计消费,下单次数,客单价,最近下单,标签");
+ foreach (var item in topCustomers)
+ {
+ var tags = item.Tags.Count == 0
+ ? string.Empty
+ : string.Join('、', item.Tags.Select(tag => tag.Label));
+
+ sb.AppendLine(string.Join(',',
+ [
+ item.Rank.ToString(CultureInfo.InvariantCulture),
+ Escape(item.Name),
+ Escape(item.PhoneMasked),
+ Escape(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
+ Escape(item.OrderCount.ToString(CultureInfo.InvariantCulture)),
+ Escape(item.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
+ Escape(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
+ Escape(tags)
+ ]));
+ }
+
+ return sb.ToString();
+ }
+
+ private static string ResolvePeriodLabel(string periodCode, int periodDays)
+ {
+ return periodCode switch
+ {
+ "7d" => "近7天",
+ "30d" => "近30天",
+ "90d" => "近90天",
+ "365d" => "近1年",
+ _ => $"近{periodDays}天"
+ };
+ }
+
+ private static string ResolveRfmColumnLabel(int columnIndex)
+ {
+ return columnIndex switch
+ {
+ 0 => "高频高额",
+ 1 => "高频低额",
+ 2 => "低频高额",
+ _ => "低频低额"
+ };
+ }
+
+ private static string Escape(string input)
+ {
+ if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
+ {
+ return input;
+ }
+
+ return $"\"{input.Replace("\"", "\"\"")}\"";
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs
new file mode 100644
index 0000000..cbab8bd
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisOverviewQueryHandler.cs
@@ -0,0 +1,94 @@
+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;
+
+///
+/// 客户分析总览查询处理器。
+///
+public sealed class GetCustomerAnalysisOverviewQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetCustomerAnalysisOverviewQuery request,
+ CancellationToken cancellationToken)
+ {
+ var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
+ var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
+ ? "30d"
+ : request.PeriodCode.Trim().ToLowerInvariant();
+
+ if (request.VisibleStoreIds.Count == 0)
+ {
+ return BuildEmptyOverview(periodCode, periodDays);
+ }
+
+ var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
+ orderRepository,
+ dapperExecutor,
+ tenantProvider,
+ request.VisibleStoreIds,
+ cancellationToken);
+
+ var nowUtc = DateTime.UtcNow;
+ var currentStart = nowUtc.AddDays(-periodDays);
+ var previousStart = currentStart.AddDays(-periodDays);
+
+ var totalCustomers = customers.Count;
+ var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
+ var previousNewCustomers = customers.Count(item =>
+ item.RegisteredAt >= previousStart &&
+ item.RegisteredAt < currentStart);
+ var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
+
+ var averageLifetimeValue = totalCustomers <= 0
+ ? 0
+ : decimal.Round(
+ customers.Sum(item => item.TotalAmount) / totalCustomers,
+ 2,
+ MidpointRounding.AwayFromZero);
+
+ return new CustomerAnalysisOverviewDto
+ {
+ PeriodCode = periodCode,
+ PeriodDays = periodDays,
+ TotalCustomers = totalCustomers,
+ NewCustomers = newCustomers,
+ GrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers),
+ NewCustomersDailyAverage = decimal.Round(
+ newCustomers / Math.Max(1m, periodDays),
+ 1,
+ MidpointRounding.AwayFromZero),
+ ActiveCustomers = activeCustomers,
+ ActiveRatePercent = CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers),
+ AverageLifetimeValue = averageLifetimeValue,
+ GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend(customers, nowUtc, 12),
+ Composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays),
+ AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers),
+ RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc),
+ TopCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10)
+ };
+ }
+
+ private static CustomerAnalysisOverviewDto BuildEmptyOverview(string periodCode, int periodDays)
+ {
+ return new CustomerAnalysisOverviewDto
+ {
+ PeriodCode = periodCode,
+ PeriodDays = periodDays,
+ GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend([], DateTime.UtcNow, 12),
+ Composition = CustomerAnalysisSegmentSupport.BuildComposition([], DateTime.UtcNow, periodDays),
+ AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution([]),
+ RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows([], DateTime.UtcNow),
+ TopCustomers = []
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs
new file mode 100644
index 0000000..1b06b23
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerAnalysisSegmentListQueryHandler.cs
@@ -0,0 +1,102 @@
+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;
+
+///
+/// 客群明细查询处理器。
+///
+public sealed class GetCustomerAnalysisSegmentListQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetCustomerAnalysisSegmentListQuery request,
+ CancellationToken cancellationToken)
+ {
+ var page = Math.Max(1, request.Page);
+ var pageSize = Math.Clamp(request.PageSize, 1, 200);
+ var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
+ var normalizedSegmentCode = CustomerAnalysisSegmentSupport.NormalizeSegmentCode(request.SegmentCode);
+ var segmentMeta = CustomerAnalysisSegmentSupport.ResolveSegmentMeta(normalizedSegmentCode);
+
+ if (request.VisibleStoreIds.Count == 0)
+ {
+ return new CustomerAnalysisSegmentListResultDto
+ {
+ SegmentCode = segmentMeta.SegmentCode,
+ SegmentTitle = segmentMeta.SegmentTitle,
+ SegmentDescription = segmentMeta.SegmentDescription,
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = 0,
+ Items = []
+ };
+ }
+
+ var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
+ orderRepository,
+ dapperExecutor,
+ tenantProvider,
+ request.VisibleStoreIds,
+ cancellationToken);
+
+ var nowUtc = DateTime.UtcNow;
+ var segmentCustomers = CustomerAnalysisSegmentSupport.FilterBySegment(
+ customers,
+ normalizedSegmentCode,
+ nowUtc,
+ periodDays);
+
+ var keywordFiltered = CustomerAnalysisSegmentSupport.ApplyKeyword(
+ segmentCustomers,
+ request.Keyword);
+
+ var sortedCustomers = keywordFiltered
+ .OrderByDescending(item => item.TotalAmount)
+ .ThenByDescending(item => item.OrderCount)
+ .ThenByDescending(item => item.LastOrderAt)
+ .ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
+ .ToList();
+
+ var pagedItems = sortedCustomers
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .Select(item => new CustomerAnalysisSegmentListItemDto
+ {
+ CustomerKey = item.CustomerKey,
+ Name = item.Name,
+ PhoneMasked = item.PhoneMasked,
+ AvatarText = item.AvatarText,
+ AvatarColor = item.AvatarColor,
+ Tags = item.Tags,
+ IsMember = item.Member.IsMember,
+ MemberTierName = item.Member.TierName,
+ TotalAmount = item.TotalAmount,
+ OrderCount = item.OrderCount,
+ AverageAmount = item.AverageAmount,
+ RegisteredAt = item.RegisteredAt,
+ LastOrderAt = item.LastOrderAt,
+ IsDimmed = item.IsDimmed
+ })
+ .ToList();
+
+ return new CustomerAnalysisSegmentListResultDto
+ {
+ SegmentCode = segmentMeta.SegmentCode,
+ SegmentTitle = segmentMeta.SegmentTitle,
+ SegmentDescription = segmentMeta.SegmentDescription,
+ Items = pagedItems,
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = sortedCustomers.Count
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs
new file mode 100644
index 0000000..854a14d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerMemberDetailQueryHandler.cs
@@ -0,0 +1,77 @@
+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;
+
+///
+/// 会员详情查询处理器。
+///
+public sealed class GetCustomerMemberDetailQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetCustomerMemberDetailQuery request,
+ CancellationToken cancellationToken)
+ {
+ var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
+ if (request.VisibleStoreIds.Count == 0 || string.IsNullOrWhiteSpace(customerKey))
+ {
+ return null;
+ }
+
+ 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;
+ }
+
+ var orderIds = customer.Orders
+ .Select(item => item.OrderId)
+ .ToList();
+
+ var itemLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
+ var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemLookup, 5);
+ var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
+ Math.Max(0, customer.OrderCount - 1),
+ customer.OrderCount);
+
+ return new CustomerMemberDetailDto
+ {
+ CustomerKey = customer.CustomerKey,
+ Name = customer.Name,
+ PhoneMasked = customer.PhoneMasked,
+ Source = customer.Source,
+ RegisteredAt = customer.RegisteredAt,
+ LastOrderAt = customer.LastOrderAt,
+ Member = customer.Member,
+ Tags = customer.Tags,
+ TotalOrders = customer.OrderCount,
+ TotalAmount = customer.TotalAmount,
+ AverageAmount = customer.AverageAmount,
+ RepurchaseRatePercent = repurchaseRatePercent,
+ RecentOrders = recentOrders
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs
new file mode 100644
index 0000000..735981f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerAnalysisCsvQuery.cs
@@ -0,0 +1,25 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户分析报表导出查询。
+///
+public sealed class ExportCustomerAnalysisCsvQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 统计周期编码(7d/30d/90d/365d)。
+ ///
+ public string PeriodCode { get; init; } = "30d";
+
+ ///
+ /// 统计周期天数。
+ ///
+ public int PeriodDays { get; init; } = 30;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs
new file mode 100644
index 0000000..1b7b9e2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisOverviewQuery.cs
@@ -0,0 +1,25 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户分析总览查询。
+///
+public sealed class GetCustomerAnalysisOverviewQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 统计周期编码(7d/30d/90d/365d)。
+ ///
+ public string PeriodCode { get; init; } = "30d";
+
+ ///
+ /// 统计周期天数。
+ ///
+ public int PeriodDays { get; init; } = 30;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs
new file mode 100644
index 0000000..4cdaad7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerAnalysisSegmentListQuery.cs
@@ -0,0 +1,45 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客群明细查询。
+///
+public sealed class GetCustomerAnalysisSegmentListQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 统计周期编码(7d/30d/90d/365d)。
+ ///
+ public string PeriodCode { get; init; } = "30d";
+
+ ///
+ /// 统计周期天数。
+ ///
+ public int PeriodDays { get; init; } = 30;
+
+ ///
+ /// 分群编码。
+ ///
+ public string SegmentCode { get; init; } = "all";
+
+ ///
+ /// 关键词(姓名/手机号)。
+ ///
+ public string? Keyword { get; init; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 10;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs
new file mode 100644
index 0000000..73173f5
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerMemberDetailQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 会员详情查询。
+///
+public sealed class GetCustomerMemberDetailQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+}