From a993b81aeb04aec90e262cd921b487b44cd90fe8 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Tue, 3 Mar 2026 14:39:33 +0800
Subject: [PATCH] =?UTF-8?q?feat(customer):=20=E5=AE=8C=E6=88=90=E5=AE=A2?=
=?UTF-8?q?=E6=88=B7=E7=94=BB=E5=83=8F=E4=BC=9A=E5=91=98=E6=91=98=E8=A6=81?=
=?UTF-8?q?=E4=B8=8E=E6=9D=83=E9=99=90=E9=93=BE=E8=B7=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
TakeoutSaaS.Docs | 2 +-
.../Customer/CustomerListContracts.cs | 562 ++++++++++
.../Controllers/CustomerController.cs | 392 +++++++
.../App/Customers/Dto/CustomerDtos.cs | 462 ++++++++
.../Handlers/CustomerAnalyticsSupport.cs | 992 ++++++++++++++++++
.../Handlers/ExportCustomerCsvQueryHandler.cs | 107 ++
.../Handlers/GetCustomerDetailQueryHandler.cs | 107 ++
.../GetCustomerListStatsQueryHandler.cs | 85 ++
.../GetCustomerProfileQueryHandler.cs | 109 ++
.../SearchCustomerListQueryHandler.cs | 80 ++
.../Queries/ExportCustomerCsvQuery.cs | 35 +
.../Queries/GetCustomerDetailQuery.cs | 20 +
.../Queries/GetCustomerListStatsQuery.cs | 35 +
.../Queries/GetCustomerProfileQuery.cs | 20 +
.../Queries/SearchCustomerListQuery.cs | 46 +
15 files changed, 3053 insertions(+), 1 deletion(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerListContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerDtos.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalyticsSupport.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerCsvQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerListStatsQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerProfileQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Handlers/SearchCustomerListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerCsvQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerListStatsQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerProfileQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Customers/Queries/SearchCustomerListQuery.cs
diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs
index c98e4ba..7486bf2 160000
--- a/TakeoutSaaS.Docs
+++ b/TakeoutSaaS.Docs
@@ -1 +1 @@
-Subproject commit c98e4ba3c49edc90031118f14ea8e26a48ff6509
+Subproject commit 7486bf272ed88a2aab96e8d37fc88e7ac7f1d493
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerListContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerListContracts.cs
new file mode 100644
index 0000000..217b224
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Customer/CustomerListContracts.cs
@@ -0,0 +1,562 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Customer;
+
+///
+/// 客户列表筛选请求。
+///
+public class CustomerListFilterRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 关键词(姓名/手机号)。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 客户标签(high_value/active/dormant/churn/new_customer)。
+ ///
+ public string? Tag { get; set; }
+
+ ///
+ /// 下单次数区间(once/two_to_five/six_to_ten/ten_plus)。
+ ///
+ public string? OrderCountRange { get; set; }
+
+ ///
+ /// 注册周期(7/30/90 或 7d/30d/90d)。
+ ///
+ public string? RegisterPeriod { get; set; }
+}
+
+///
+/// 客户列表分页请求。
+///
+public sealed class CustomerListRequest : CustomerListFilterRequest
+{
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 客户详情请求。
+///
+public sealed class CustomerDetailRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+}
+
+///
+/// 客户画像请求。
+///
+public sealed class CustomerProfileRequest
+{
+ ///
+ /// 门店 ID(可选,未传表示当前商户全部可见门店)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+}
+
+///
+/// 客户标签响应。
+///
+public sealed class CustomerTagResponse
+{
+ ///
+ /// 标签编码。
+ ///
+ public string Code { get; set; } = string.Empty;
+
+ ///
+ /// 标签文案。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 标签色调(orange/blue/green/gray/red)。
+ ///
+ public string Tone { get; set; } = "blue";
+}
+
+///
+/// 客户列表行响应。
+///
+public sealed class CustomerListItemResponse
+{
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ 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 int OrderCount { get; set; }
+
+ ///
+ /// 下单次数条形宽度百分比。
+ ///
+ public int OrderCountBarPercent { get; set; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; set; }
+
+ ///
+ /// 最近下单时间(yyyy-MM-dd)。
+ ///
+ public string LastOrderAt { get; set; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public List Tags { get; set; } = [];
+
+ ///
+ /// 是否弱化展示。
+ ///
+ public bool IsDimmed { get; set; }
+}
+
+///
+/// 客户列表响应。
+///
+public sealed class CustomerListResultResponse
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+}
+
+///
+/// 客户列表统计响应。
+///
+public sealed class CustomerListStatsResponse
+{
+ ///
+ /// 客户总数。
+ ///
+ public int TotalCustomers { get; set; }
+
+ ///
+ /// 本月新增客户数。
+ ///
+ public int MonthlyNewCustomers { get; set; }
+
+ ///
+ /// 本月较上月增长百分比。
+ ///
+ public decimal MonthlyGrowthRatePercent { get; set; }
+
+ ///
+ /// 活跃客户数(近 30 天有下单)。
+ ///
+ public int ActiveCustomers { get; set; }
+
+ ///
+ /// 近 30 天客均消费(按订单均值)。
+ ///
+ public decimal AverageAmountLast30Days { get; set; }
+}
+
+///
+/// 客户偏好响应。
+///
+public sealed class CustomerPreferenceResponse
+{
+ ///
+ /// 偏好品类。
+ ///
+ public List PreferredCategories { get; set; } = [];
+
+ ///
+ /// 偏好下单时段。
+ ///
+ public string PreferredOrderPeaks { get; set; } = string.Empty;
+
+ ///
+ /// 偏好履约方式。
+ ///
+ public string PreferredDelivery { get; set; } = string.Empty;
+
+ ///
+ /// 偏好支付方式。
+ ///
+ public string PreferredPaymentMethod { get; set; } = string.Empty;
+
+ ///
+ /// 平均配送距离文案(当前无配送距离数据时返回空字符串)。
+ ///
+ public string AverageDeliveryDistance { get; set; } = string.Empty;
+}
+
+///
+/// 客户常购商品响应。
+///
+public sealed class CustomerTopProductResponse
+{
+ ///
+ /// 排名。
+ ///
+ public int Rank { get; set; }
+
+ ///
+ /// 商品名称。
+ ///
+ public string ProductName { get; set; } = string.Empty;
+
+ ///
+ /// 购买次数。
+ ///
+ public int Count { get; set; }
+
+ ///
+ /// 占比(0-100)。
+ ///
+ public decimal ProportionPercent { get; set; }
+}
+
+///
+/// 客户月度趋势响应。
+///
+public sealed class CustomerTrendPointResponse
+{
+ ///
+ /// 月份标签。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 消费金额。
+ ///
+ public decimal Amount { get; set; }
+}
+
+///
+/// 客户最近订单响应。
+///
+public sealed class CustomerRecentOrderResponse
+{
+ ///
+ /// 订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 订单金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 商品摘要。
+ ///
+ public string ItemsSummary { get; set; } = string.Empty;
+
+ ///
+ /// 履约方式。
+ ///
+ public string DeliveryType { get; set; } = string.Empty;
+
+ ///
+ /// 订单状态。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 下单时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string OrderedAt { get; set; } = string.Empty;
+}
+
+///
+/// 客户会员摘要响应。
+///
+public sealed class CustomerMemberSummaryResponse
+{
+ ///
+ /// 是否会员。
+ ///
+ public bool IsMember { get; set; }
+
+ ///
+ /// 会员等级名称。
+ ///
+ public string TierName { get; set; } = string.Empty;
+
+ ///
+ /// 积分余额。
+ ///
+ public int PointsBalance { get; set; }
+
+ ///
+ /// 成长值。
+ ///
+ public int GrowthValue { get; set; }
+
+ ///
+ /// 入会时间(yyyy-MM-dd)。
+ ///
+ public string JoinedAt { get; set; } = string.Empty;
+}
+
+///
+/// 客户详情响应。
+///
+public sealed class CustomerDetailResponse
+{
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; set; } = string.Empty;
+
+ ///
+ /// 注册时间(yyyy-MM-dd)。
+ ///
+ public string RegisteredAt { get; set; } = string.Empty;
+
+ ///
+ /// 首次下单时间(yyyy-MM-dd)。
+ ///
+ public string FirstOrderAt { get; set; } = string.Empty;
+
+ ///
+ /// 客户来源。
+ ///
+ public string Source { get; set; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public List Tags { get; set; } = [];
+
+ ///
+ /// 会员摘要。
+ ///
+ public CustomerMemberSummaryResponse Member { get; set; } = new();
+
+ ///
+ /// 累计下单次数。
+ ///
+ public int TotalOrders { get; set; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; set; }
+
+ ///
+ /// 复购率(百分比)。
+ ///
+ public decimal RepurchaseRatePercent { get; set; }
+
+ ///
+ /// 消费偏好。
+ ///
+ public CustomerPreferenceResponse Preference { get; set; } = new();
+
+ ///
+ /// 常购商品 Top 5。
+ ///
+ public List TopProducts { get; set; } = [];
+
+ ///
+ /// 近 6 月消费趋势。
+ ///
+ public List Trend { get; set; } = [];
+
+ ///
+ /// 最近订单(最多 3 条)。
+ ///
+ public List RecentOrders { get; set; } = [];
+}
+
+///
+/// 客户画像响应。
+///
+public sealed class CustomerProfileResponse
+{
+ ///
+ /// 客户标识。
+ ///
+ public string CustomerKey { get; set; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; set; } = string.Empty;
+
+ ///
+ /// 注册时间(yyyy-MM-dd)。
+ ///
+ public string RegisteredAt { get; set; } = string.Empty;
+
+ ///
+ /// 首次下单时间(yyyy-MM-dd)。
+ ///
+ public string FirstOrderAt { get; set; } = string.Empty;
+
+ ///
+ /// 客户来源。
+ ///
+ public string Source { get; set; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public List Tags { get; set; } = [];
+
+ ///
+ /// 会员摘要。
+ ///
+ public CustomerMemberSummaryResponse Member { get; set; } = new();
+
+ ///
+ /// 累计下单次数。
+ ///
+ public int TotalOrders { get; set; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; set; }
+
+ ///
+ /// 复购率(百分比)。
+ ///
+ public decimal RepurchaseRatePercent { get; set; }
+
+ ///
+ /// 平均下单间隔(天)。
+ ///
+ public decimal AverageOrderIntervalDays { get; set; }
+
+ ///
+ /// 消费偏好。
+ ///
+ public CustomerPreferenceResponse Preference { get; set; } = new();
+
+ ///
+ /// 常购商品 Top 5。
+ ///
+ public List TopProducts { get; set; } = [];
+
+ ///
+ /// 近 12 月消费趋势。
+ ///
+ public List Trend { get; set; } = [];
+
+ ///
+ /// 最近订单(最多 5 条)。
+ ///
+ public List RecentOrders { get; set; } = [];
+}
+
+///
+/// 客户导出响应。
+///
+public sealed class CustomerExportResponse
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// 文件 Base64。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 导出总数。
+ ///
+ public int TotalCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
new file mode 100644
index 0000000..3607158
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/CustomerController.cs
@@ -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;
+
+///
+/// 客户管理列表与画像。
+///
+[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";
+
+ ///
+ /// 获取客户列表。
+ ///
+ [HttpGet("list")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(new CustomerListResultResponse
+ {
+ Items = result.Items.Select(MapListItem).ToList(),
+ Total = result.TotalCount,
+ Page = result.Page,
+ PageSize = result.PageSize
+ });
+ }
+
+ ///
+ /// 获取客户列表统计。
+ ///
+ [HttpGet("stats")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(new CustomerListStatsResponse
+ {
+ TotalCustomers = result.TotalCustomers,
+ MonthlyNewCustomers = result.MonthlyNewCustomers,
+ MonthlyGrowthRatePercent = result.MonthlyGrowthRatePercent,
+ ActiveCustomers = result.ActiveCustomers,
+ AverageAmountLast30Days = result.AverageAmountLast30Days
+ });
+ }
+
+ ///
+ /// 获取客户详情(一级抽屉)。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [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(ProfilePermission)]
+ [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));
+ }
+
+ ///
+ /// 导出客户 CSV。
+ ///
+ [HttpGet("export")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.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 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);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerDtos.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerDtos.cs
new file mode 100644
index 0000000..d2a94ee
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Dto/CustomerDtos.cs
@@ -0,0 +1,462 @@
+namespace TakeoutSaaS.Application.App.Customers.Dto;
+
+///
+/// 客户标签 DTO。
+///
+public sealed class CustomerTagDto
+{
+ ///
+ /// 标签编码。
+ ///
+ public string Code { get; init; } = string.Empty;
+
+ ///
+ /// 标签文案。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 标签色调(orange/blue/green/gray/red)。
+ ///
+ public string Tone { get; init; } = "blue";
+}
+
+///
+/// 客户列表行 DTO。
+///
+public sealed class CustomerListItemDto
+{
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ 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 int OrderCount { get; init; }
+
+ ///
+ /// 下单次数条形宽度百分比。
+ ///
+ public int OrderCountBarPercent { get; init; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; init; }
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 最近下单时间。
+ ///
+ public DateTime LastOrderAt { get; init; }
+
+ ///
+ /// 客户标签。
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+
+ ///
+ /// 是否弱化展示。
+ ///
+ public bool IsDimmed { get; init; }
+}
+
+///
+/// 客户列表统计 DTO。
+///
+public sealed class CustomerListStatsDto
+{
+ ///
+ /// 客户总数。
+ ///
+ public int TotalCustomers { get; init; }
+
+ ///
+ /// 本月新增客户数。
+ ///
+ public int MonthlyNewCustomers { get; init; }
+
+ ///
+ /// 本月较上月增长百分比。
+ ///
+ public decimal MonthlyGrowthRatePercent { get; init; }
+
+ ///
+ /// 活跃客户数(近 30 天有下单)。
+ ///
+ public int ActiveCustomers { get; init; }
+
+ ///
+ /// 近 30 天客均消费(按订单均值)。
+ ///
+ public decimal AverageAmountLast30Days { get; init; }
+}
+
+///
+/// 客户偏好 DTO。
+///
+public sealed class CustomerPreferenceDto
+{
+ ///
+ /// 偏好品类。
+ ///
+ public IReadOnlyList PreferredCategories { get; init; } = [];
+
+ ///
+ /// 偏好下单时段。
+ ///
+ public string PreferredOrderPeaks { get; init; } = string.Empty;
+
+ ///
+ /// 偏好履约方式。
+ ///
+ public string PreferredDelivery { get; init; } = string.Empty;
+
+ ///
+ /// 偏好支付方式。
+ ///
+ public string PreferredPaymentMethod { get; init; } = string.Empty;
+
+ ///
+ /// 平均配送距离文案。
+ ///
+ public string AverageDeliveryDistance { get; init; } = string.Empty;
+}
+
+///
+/// 客户常购商品 DTO。
+///
+public sealed class CustomerTopProductDto
+{
+ ///
+ /// 排名。
+ ///
+ public int Rank { get; init; }
+
+ ///
+ /// 商品名称。
+ ///
+ public string ProductName { get; init; } = string.Empty;
+
+ ///
+ /// 购买次数。
+ ///
+ public int Count { get; init; }
+
+ ///
+ /// 占比(0-100)。
+ ///
+ public decimal ProportionPercent { get; init; }
+}
+
+///
+/// 客户趋势点 DTO。
+///
+public sealed class CustomerTrendPointDto
+{
+ ///
+ /// 月份标签。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 消费金额。
+ ///
+ public decimal Amount { get; init; }
+}
+
+///
+/// 客户最近订单 DTO。
+///
+public sealed class CustomerRecentOrderDto
+{
+ ///
+ /// 订单号。
+ ///
+ public string OrderNo { get; init; } = string.Empty;
+
+ ///
+ /// 下单时间。
+ ///
+ public DateTime OrderedAt { get; init; }
+
+ ///
+ /// 订单金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 商品摘要。
+ ///
+ public string ItemsSummary { get; init; } = string.Empty;
+
+ ///
+ /// 履约方式文案。
+ ///
+ public string DeliveryType { get; init; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string Status { get; init; } = string.Empty;
+}
+
+///
+/// 客户会员摘要 DTO。
+///
+public sealed class CustomerMemberSummaryDto
+{
+ ///
+ /// 是否会员。
+ ///
+ public bool IsMember { get; init; }
+
+ ///
+ /// 会员等级名称。
+ ///
+ public string TierName { get; init; } = string.Empty;
+
+ ///
+ /// 积分余额。
+ ///
+ public int PointsBalance { get; init; }
+
+ ///
+ /// 成长值。
+ ///
+ public int GrowthValue { get; init; }
+
+ ///
+ /// 入会时间。
+ ///
+ public DateTime? JoinedAt { get; init; }
+}
+
+///
+/// 客户详情 DTO。
+///
+public sealed class CustomerDetailDto
+{
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; init; } = string.Empty;
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 首次下单时间。
+ ///
+ public DateTime FirstOrderAt { get; init; }
+
+ ///
+ /// 客户来源。
+ ///
+ public string Source { get; init; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+
+ ///
+ /// 会员摘要。
+ ///
+ public CustomerMemberSummaryDto Member { get; init; } = new();
+
+ ///
+ /// 累计下单次数。
+ ///
+ public int TotalOrders { get; init; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; init; }
+
+ ///
+ /// 复购率(百分比)。
+ ///
+ public decimal RepurchaseRatePercent { get; init; }
+
+ ///
+ /// 偏好数据。
+ ///
+ public CustomerPreferenceDto Preference { get; init; } = new();
+
+ ///
+ /// 常购商品 Top 5。
+ ///
+ public IReadOnlyList TopProducts { get; init; } = [];
+
+ ///
+ /// 趋势数据。
+ ///
+ public IReadOnlyList Trend { get; init; } = [];
+
+ ///
+ /// 最近订单。
+ ///
+ public IReadOnlyList RecentOrders { get; init; } = [];
+}
+
+///
+/// 客户画像 DTO。
+///
+public sealed class CustomerProfileDto
+{
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+
+ ///
+ /// 客户名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 手机号(脱敏)。
+ ///
+ public string PhoneMasked { get; init; } = string.Empty;
+
+ ///
+ /// 注册时间。
+ ///
+ public DateTime RegisteredAt { get; init; }
+
+ ///
+ /// 首次下单时间。
+ ///
+ public DateTime FirstOrderAt { get; init; }
+
+ ///
+ /// 客户来源。
+ ///
+ public string Source { get; init; } = string.Empty;
+
+ ///
+ /// 客户标签。
+ ///
+ public IReadOnlyList Tags { get; init; } = [];
+
+ ///
+ /// 会员摘要。
+ ///
+ public CustomerMemberSummaryDto Member { get; init; } = new();
+
+ ///
+ /// 累计下单次数。
+ ///
+ public int TotalOrders { get; init; }
+
+ ///
+ /// 累计消费。
+ ///
+ public decimal TotalAmount { get; init; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageAmount { get; init; }
+
+ ///
+ /// 复购率(百分比)。
+ ///
+ public decimal RepurchaseRatePercent { get; init; }
+
+ ///
+ /// 平均下单间隔(天)。
+ ///
+ public decimal AverageOrderIntervalDays { get; init; }
+
+ ///
+ /// 偏好数据。
+ ///
+ public CustomerPreferenceDto Preference { get; init; } = new();
+
+ ///
+ /// 常购商品 Top 5。
+ ///
+ public IReadOnlyList TopProducts { get; init; } = [];
+
+ ///
+ /// 趋势数据。
+ ///
+ public IReadOnlyList Trend { get; init; } = [];
+
+ ///
+ /// 最近订单。
+ ///
+ public IReadOnlyList RecentOrders { get; init; } = [];
+}
+
+///
+/// 客户导出 DTO。
+///
+public sealed class CustomerExportDto
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; init; } = string.Empty;
+
+ ///
+ /// 文件 Base64。
+ ///
+ public string FileContentBase64 { get; init; } = string.Empty;
+
+ ///
+ /// 导出总数。
+ ///
+ public int TotalCount { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalyticsSupport.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalyticsSupport.cs
new file mode 100644
index 0000000..3cfdb56
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/CustomerAnalyticsSupport.cs
@@ -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 BuildTags(
+ decimal totalAmount,
+ decimal averageAmount,
+ int orderCount,
+ DateTime registeredAt,
+ DateTime lastOrderAt,
+ DateTime nowUtc)
+ {
+ var tagList = new List();
+
+ // 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 ApplyFilters(
+ IReadOnlyList 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 BuildMonthlyTrend(
+ IReadOnlyList 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(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 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 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 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 ResolvePreferredPaymentMethodAsync(
+ IOrderRepository orderRepository,
+ long tenantId,
+ IReadOnlyList 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();
+ 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 BuildTopProducts(
+ IReadOnlyDictionary> itemsLookup,
+ IReadOnlyList orderIds,
+ int takeCount)
+ {
+ if (orderIds.Count == 0 || takeCount <= 0)
+ {
+ return [];
+ }
+
+ // 1. 汇总商品购买次数
+ var productCounter = new Dictionary();
+ 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> ResolvePreferredCategoriesAsync(
+ IProductRepository productRepository,
+ long tenantId,
+ IReadOnlyDictionary> itemsLookup,
+ IReadOnlyList orderIds,
+ CancellationToken cancellationToken)
+ {
+ if (orderIds.Count == 0)
+ {
+ return [];
+ }
+
+ // 1. 汇总分类出现频次
+ var productCache = new Dictionary();
+ var categoryCounter = new Dictionary();
+
+ 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(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 BuildRecentOrders(
+ IReadOnlyList orders,
+ IReadOnlyDictionary> 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> 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> LoadCustomersAsync(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider,
+ IReadOnlyCollection 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> LoadMemberProfileLookupAsync(
+ IDapperExecutor dapperExecutor,
+ long tenantId,
+ IReadOnlySet 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(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 Orders { get; init; } = [];
+ internal string PhoneMasked { get; init; } = string.Empty;
+ internal DateTime RegisteredAt { get; init; }
+ internal string Source { get; init; } = string.Empty;
+ internal IReadOnlyList 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;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerCsvQueryHandler.cs
new file mode 100644
index 0000000..e2d523f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/ExportCustomerCsvQueryHandler.cs
@@ -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;
+
+///
+/// 客户列表 CSV 导出处理器。
+///
+public sealed class ExportCustomerCsvQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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 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 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("\"", "\"\"")}\"";
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerDetailQueryHandler.cs
new file mode 100644
index 0000000..93f6092
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerDetailQueryHandler.cs
@@ -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;
+
+///
+/// 客户详情查询处理器。
+///
+public sealed class GetCustomerDetailQueryHandler(
+ IOrderRepository orderRepository,
+ IProductRepository productRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerListStatsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerListStatsQueryHandler.cs
new file mode 100644
index 0000000..6627bc2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerListStatsQueryHandler.cs
@@ -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;
+
+///
+/// 客户列表统计查询处理器。
+///
+public sealed class GetCustomerListStatsQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerProfileQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerProfileQueryHandler.cs
new file mode 100644
index 0000000..e6f2571
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/GetCustomerProfileQueryHandler.cs
@@ -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;
+
+///
+/// 客户画像查询处理器。
+///
+public sealed class GetCustomerProfileQueryHandler(
+ IOrderRepository orderRepository,
+ IProductRepository productRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/SearchCustomerListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/SearchCustomerListQueryHandler.cs
new file mode 100644
index 0000000..94a687d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Handlers/SearchCustomerListQueryHandler.cs
@@ -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;
+
+///
+/// 客户列表查询处理器。
+///
+public sealed class SearchCustomerListQueryHandler(
+ IOrderRepository orderRepository,
+ IDapperExecutor dapperExecutor,
+ ITenantProvider tenantProvider)
+ : IRequestHandler>
+{
+ ///
+ public async Task> 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([], 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(items, page, pageSize, totalCount);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerCsvQuery.cs
new file mode 100644
index 0000000..189e818
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/ExportCustomerCsvQuery.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户列表 CSV 导出查询。
+///
+public sealed class ExportCustomerCsvQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 关键词(姓名/手机号)。
+ ///
+ public string? Keyword { get; init; }
+
+ ///
+ /// 客户标签筛选。
+ ///
+ public string? Tag { get; init; }
+
+ ///
+ /// 下单次数区间。
+ ///
+ public string? OrderCountRange { get; init; }
+
+ ///
+ /// 注册周期天数(7/30/90)。
+ ///
+ public int? RegisterPeriodDays { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerDetailQuery.cs
new file mode 100644
index 0000000..cf87f78
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerDetailQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户详情查询。
+///
+public sealed class GetCustomerDetailQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerListStatsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerListStatsQuery.cs
new file mode 100644
index 0000000..64a67d8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerListStatsQuery.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户列表统计查询。
+///
+public sealed class GetCustomerListStatsQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 关键词(姓名/手机号)。
+ ///
+ public string? Keyword { get; init; }
+
+ ///
+ /// 客户标签筛选。
+ ///
+ public string? Tag { get; init; }
+
+ ///
+ /// 下单次数区间。
+ ///
+ public string? OrderCountRange { get; init; }
+
+ ///
+ /// 注册周期天数(7/30/90)。
+ ///
+ public int? RegisterPeriodDays { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerProfileQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerProfileQuery.cs
new file mode 100644
index 0000000..c802738
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/GetCustomerProfileQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户画像查询。
+///
+public sealed class GetCustomerProfileQuery : IRequest
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 客户标识(手机号归一化)。
+ ///
+ public string CustomerKey { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Customers/Queries/SearchCustomerListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/SearchCustomerListQuery.cs
new file mode 100644
index 0000000..20fdc76
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Customers/Queries/SearchCustomerListQuery.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Customers.Dto;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Customers.Queries;
+
+///
+/// 客户列表查询。
+///
+public sealed class SearchCustomerListQuery : IRequest>
+{
+ ///
+ /// 可见门店 ID 集合。
+ ///
+ public IReadOnlyCollection VisibleStoreIds { get; init; } = [];
+
+ ///
+ /// 关键词(姓名/手机号)。
+ ///
+ public string? Keyword { get; init; }
+
+ ///
+ /// 客户标签筛选。
+ ///
+ public string? Tag { get; init; }
+
+ ///
+ /// 下单次数区间。
+ ///
+ public string? OrderCountRange { get; init; }
+
+ ///
+ /// 注册周期天数(7/30/90)。
+ ///
+ public int? RegisterPeriodDays { get; init; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 10;
+}