diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs
index 6680599..2bceb20 160000
--- a/TakeoutSaaS.Docs
+++ b/TakeoutSaaS.Docs
@@ -1 +1 @@
-Subproject commit 66805999120ba0e2df1e3c11100f523e2d3a7fef
+Subproject commit 2bceb20baed29fdcc48774b6b65fb9121e806b6f
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs
new file mode 100644
index 0000000..2fc6dd2
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs
@@ -0,0 +1,585 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Member;
+
+///
+/// 消息触达统计请求。
+///
+public sealed class MemberMessageReachStatsRequest
+{
+ ///
+ /// 门店 ID(可选)。
+ ///
+ public string? StoreId { get; set; }
+}
+
+///
+/// 消息列表请求。
+///
+public sealed class MemberMessageReachListRequest
+{
+ ///
+ /// 状态过滤(draft/pending/sending/sent/failed)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 渠道过滤(inapp/sms/wechat-mini)。
+ ///
+ public string? Channel { get; set; }
+
+ ///
+ /// 关键词(标题)。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 消息详情请求。
+///
+public sealed class MemberMessageReachDetailRequest
+{
+ ///
+ /// 消息 ID。
+ ///
+ public string MessageId { get; set; } = string.Empty;
+}
+
+///
+/// 保存消息请求。
+///
+public sealed class SaveMemberMessageReachRequest
+{
+ ///
+ /// 消息 ID(编辑时传)。
+ ///
+ public string? MessageId { get; set; }
+
+ ///
+ /// 门店 ID(可选)。
+ ///
+ public string? StoreId { get; set; }
+
+ ///
+ /// 模板 ID(可选)。
+ ///
+ public string? TemplateId { get; set; }
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 内容。
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// 发送渠道。
+ ///
+ public List Channels { get; set; } = [];
+
+ ///
+ /// 目标类型(all/tag)。
+ ///
+ public string AudienceType { get; set; } = "all";
+
+ ///
+ /// 目标标签。
+ ///
+ public List AudienceTags { get; set; } = [];
+
+ ///
+ /// 发送时间类型(immediate/scheduled)。
+ ///
+ public string ScheduleType { get; set; } = "immediate";
+
+ ///
+ /// 定时发送时间(UTC 或本地时间,后端统一转 UTC)。
+ ///
+ public DateTime? ScheduledAt { get; set; }
+
+ ///
+ /// 提交动作(draft/send)。
+ ///
+ public string SubmitAction { get; set; } = "draft";
+}
+
+///
+/// 删除消息请求。
+///
+public sealed class DeleteMemberMessageReachRequest
+{
+ ///
+ /// 消息 ID。
+ ///
+ public string MessageId { get; set; } = string.Empty;
+}
+
+///
+/// 估算人群请求。
+///
+public sealed class MemberMessageAudienceEstimateRequest
+{
+ ///
+ /// 目标类型(all/tag)。
+ ///
+ public string AudienceType { get; set; } = "all";
+
+ ///
+ /// 标签。
+ ///
+ public List Tags { get; set; } = [];
+}
+
+///
+/// 模板列表请求。
+///
+public sealed class MemberMessageTemplateListRequest
+{
+ ///
+ /// 模板分类(marketing/notice/recall)。
+ ///
+ public string? Category { get; set; }
+
+ ///
+ /// 关键词(模板名称)。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 模板详情请求。
+///
+public sealed class MemberMessageTemplateDetailRequest
+{
+ ///
+ /// 模板 ID。
+ ///
+ public string TemplateId { get; set; } = string.Empty;
+}
+
+///
+/// 保存模板请求。
+///
+public sealed class SaveMemberMessageTemplateRequest
+{
+ ///
+ /// 模板 ID(编辑时传)。
+ ///
+ public string? TemplateId { get; set; }
+
+ ///
+ /// 模板名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 模板分类(marketing/notice/recall)。
+ ///
+ public string Category { get; set; } = "notice";
+
+ ///
+ /// 模板内容。
+ ///
+ public string Content { get; set; } = string.Empty;
+}
+
+///
+/// 删除模板请求。
+///
+public sealed class DeleteMemberMessageTemplateRequest
+{
+ ///
+ /// 模板 ID。
+ ///
+ public string TemplateId { get; set; } = string.Empty;
+}
+
+///
+/// 消息触达统计响应。
+///
+public sealed class MemberMessageReachStatsResponse
+{
+ ///
+ /// 本月发送条数。
+ ///
+ public int MonthlySentCount { get; set; }
+
+ ///
+ /// 触达人数。
+ ///
+ public int ReachMemberCount { get; set; }
+
+ ///
+ /// 打开率(百分比)。
+ ///
+ public decimal OpenRate { get; set; }
+
+ ///
+ /// 转化率(百分比)。
+ ///
+ public decimal ConversionRate { get; set; }
+}
+
+///
+/// 消息列表项响应。
+///
+public sealed class MemberMessageReachListItemResponse
+{
+ ///
+ /// 消息 ID。
+ ///
+ public string MessageId { get; set; } = string.Empty;
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 渠道。
+ ///
+ public List Channels { get; set; } = [];
+
+ ///
+ /// 目标文案。
+ ///
+ public string AudienceText { get; set; } = string.Empty;
+
+ ///
+ /// 预计触达人数。
+ ///
+ public int EstimatedReachCount { get; set; }
+
+ ///
+ /// 状态。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 发送时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? SentAt { get; set; }
+
+ ///
+ /// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? ScheduledAt { get; set; }
+
+ ///
+ /// 打开率(百分比)。
+ ///
+ public decimal OpenRate { get; set; }
+
+ ///
+ /// 转化率(百分比)。
+ ///
+ public decimal ConversionRate { get; set; }
+}
+
+///
+/// 消息列表响应。
+///
+public sealed class MemberMessageReachListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 收件明细响应。
+///
+public sealed class MemberMessageReachRecipientResponse
+{
+ ///
+ /// 会员 ID。
+ ///
+ public string MemberId { get; set; } = string.Empty;
+
+ ///
+ /// 渠道。
+ ///
+ public string Channel { get; set; } = string.Empty;
+
+ ///
+ /// 状态。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 手机号。
+ ///
+ public string? Mobile { get; set; }
+
+ ///
+ /// OpenId。
+ ///
+ public string? OpenId { get; set; }
+
+ ///
+ /// 发送时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? SentAt { get; set; }
+
+ ///
+ /// 已读时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? ReadAt { get; set; }
+
+ ///
+ /// 转化时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? ConvertedAt { get; set; }
+
+ ///
+ /// 错误信息。
+ ///
+ public string? ErrorMessage { get; set; }
+}
+
+///
+/// 消息详情响应。
+///
+public sealed class MemberMessageReachDetailResponse
+{
+ ///
+ /// 消息 ID。
+ ///
+ public string MessageId { get; set; } = string.Empty;
+
+ ///
+ /// 模板 ID。
+ ///
+ public string? TemplateId { get; set; }
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 内容。
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// 渠道。
+ ///
+ public List Channels { get; set; } = [];
+
+ ///
+ /// 目标类型。
+ ///
+ public string AudienceType { get; set; } = string.Empty;
+
+ ///
+ /// 目标标签。
+ ///
+ public List AudienceTags { get; set; } = [];
+
+ ///
+ /// 目标文案。
+ ///
+ public string AudienceText { get; set; } = string.Empty;
+
+ ///
+ /// 预计触达人数。
+ ///
+ public int EstimatedReachCount { get; set; }
+
+ ///
+ /// 发送时间类型。
+ ///
+ public string ScheduleType { get; set; } = string.Empty;
+
+ ///
+ /// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? ScheduledAt { get; set; }
+
+ ///
+ /// 发送状态。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 实际发送时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? SentAt { get; set; }
+
+ ///
+ /// 成功发送数。
+ ///
+ public int SentCount { get; set; }
+
+ ///
+ /// 已读数。
+ ///
+ public int ReadCount { get; set; }
+
+ ///
+ /// 转化数。
+ ///
+ public int ConvertedCount { get; set; }
+
+ ///
+ /// 打开率(百分比)。
+ ///
+ public decimal OpenRate { get; set; }
+
+ ///
+ /// 转化率(百分比)。
+ ///
+ public decimal ConversionRate { get; set; }
+
+ ///
+ /// 错误信息。
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// 收件明细。
+ ///
+ public List Recipients { get; set; } = [];
+}
+
+///
+/// 消息调度元信息响应。
+///
+public sealed class MemberMessageDispatchMetaResponse
+{
+ ///
+ /// 消息 ID。
+ ///
+ public string MessageId { get; set; } = string.Empty;
+
+ ///
+ /// 状态。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 时间类型。
+ ///
+ public string ScheduleType { get; set; } = string.Empty;
+
+ ///
+ /// 定时发送时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? ScheduledAt { get; set; }
+
+ ///
+ /// Hangfire 任务 ID。
+ ///
+ public string? HangfireJobId { get; set; }
+}
+
+///
+/// 模板响应。
+///
+public sealed class MemberMessageTemplateResponse
+{
+ ///
+ /// 模板 ID。
+ ///
+ public string TemplateId { get; set; } = string.Empty;
+
+ ///
+ /// 名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 分类。
+ ///
+ public string Category { get; set; } = string.Empty;
+
+ ///
+ /// 内容。
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// 使用次数。
+ ///
+ public int UsageCount { get; set; }
+
+ ///
+ /// 最近使用时间(yyyy-MM-dd HH:mm:ss)。
+ ///
+ public string? LastUsedAt { get; set; }
+}
+
+///
+/// 模板列表响应。
+///
+public sealed class MemberMessageTemplateListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 目标人群估算响应。
+///
+public sealed class MemberMessageAudienceEstimateResponse
+{
+ ///
+ /// 预计触达人数。
+ ///
+ public int ReachCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs
new file mode 100644
index 0000000..caaa250
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs
@@ -0,0 +1,428 @@
+using System.Globalization;
+using Asp.Versioning;
+using Hangfire;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
+using TakeoutSaaS.Application.App.Members.MessageReach.Services;
+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.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Member;
+using TakeoutSaaS.TenantApi.Services;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 会员消息触达管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/member/message-reach")]
+public sealed class MemberMessageReachController(
+ IMemberMessageReachAppService memberMessageReachAppService,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService)
+ : BaseApiController
+{
+ private const string ViewPermission = "tenant:member:message-reach:view";
+ private const string ManagePermission = "tenant:member:message-reach:manage";
+
+ ///
+ /// 获取页面统计。
+ ///
+ [HttpGet("stats")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Stats(
+ [FromQuery] MemberMessageReachStatsRequest request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
+ var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken);
+ return ApiResponse.Ok(new MemberMessageReachStatsResponse
+ {
+ MonthlySentCount = result.MonthlySentCount,
+ ReachMemberCount = result.ReachMemberCount,
+ OpenRate = result.OpenRate,
+ ConversionRate = result.ConversionRate
+ });
+ }
+
+ ///
+ /// 分页查询消息列表。
+ ///
+ [HttpGet("list")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> List(
+ [FromQuery] MemberMessageReachListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = ResolveTenantId();
+ var result = await memberMessageReachAppService.SearchMessagesAsync(
+ tenantId,
+ new SearchMemberMessageInput
+ {
+ Status = request.Status,
+ Channel = request.Channel,
+ Keyword = request.Keyword,
+ Page = request.Page,
+ PageSize = request.PageSize
+ },
+ cancellationToken);
+
+ return ApiResponse.Ok(new MemberMessageReachListResultResponse
+ {
+ Items = result.Items.Select(MapMessageListItem).ToList(),
+ Page = result.Page,
+ PageSize = result.PageSize,
+ TotalCount = result.TotalCount
+ });
+ }
+
+ ///
+ /// 获取消息详情。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] MemberMessageReachDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = ResolveTenantId();
+ var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
+ var result = await memberMessageReachAppService.GetMessageDetailAsync(tenantId, messageId, cancellationToken);
+ if (result is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "消息不存在");
+ }
+
+ return ApiResponse.Ok(MapMessageDetail(result));
+ }
+
+ ///
+ /// 保存消息(草稿/发送)。
+ ///
+ [HttpPost("save")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Save(
+ [FromBody] SaveMemberMessageReachRequest request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
+ var messageId = StoreApiHelpers.ParseSnowflakeOrNull(request.MessageId);
+ var previousMeta = messageId.HasValue
+ ? await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, messageId.Value, cancellationToken)
+ : null;
+
+ var saved = await memberMessageReachAppService.SaveMessageAsync(
+ tenantId,
+ new SaveMemberMessageInput
+ {
+ MessageId = messageId,
+ StoreId = StoreApiHelpers.ParseSnowflakeOrNull(request.StoreId),
+ TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
+ Title = request.Title,
+ Content = request.Content,
+ Channels = request.Channels,
+ AudienceType = request.AudienceType,
+ AudienceTags = request.AudienceTags,
+ ScheduleType = request.ScheduleType,
+ ScheduledAt = request.ScheduledAt,
+ SubmitAction = request.SubmitAction
+ },
+ cancellationToken);
+
+ // 1. 清理旧任务(若存在)。
+ if (!string.IsNullOrWhiteSpace(previousMeta?.HangfireJobId))
+ {
+ BackgroundJob.Delete(previousMeta.HangfireJobId);
+ }
+
+ // 2. 发送动作创建新任务并回写任务 ID。
+ if (string.Equals(request.SubmitAction, "send", StringComparison.OrdinalIgnoreCase))
+ {
+ var newJobId = ScheduleDispatchJob(saved.MessageId, saved.ScheduleType, saved.ScheduledAt);
+ await memberMessageReachAppService.BindDispatchJobAsync(tenantId, saved.MessageId, newJobId, cancellationToken);
+ }
+
+ // 3. 返回最新调度状态。
+ var latest = await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, saved.MessageId, cancellationToken);
+ return ApiResponse.Ok(MapDispatchMeta(latest ?? saved));
+ }
+
+ ///
+ /// 删除消息。
+ ///
+ [HttpPost("delete")]
+ [PermissionAuthorize(ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse