From b5aa060faf7413f153fdf4fed89bd9e301aca8dd Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 4 Mar 2026 13:35:22 +0800
Subject: [PATCH] feat(member): add message reach backend module and docs seeds
---
TakeoutSaaS.Docs | 2 +-
.../Member/MemberMessageReachContracts.cs | 585 +++++++++++
.../MemberMessageReachController.cs | 428 ++++++++
src/Api/TakeoutSaaS.TenantApi/Program.cs | 16 +
.../MemberMessageReachDispatchJobRunner.cs | 50 +
.../appsettings.Development.json | 43 +
.../appsettings.Production.json | 43 +
...pApplicationServiceCollectionExtensions.cs | 4 +
.../Dto/MemberMessageReachDtos.cs | 530 ++++++++++
.../MessageReach/MemberMessageReachMapping.cs | 260 +++++
.../Options/MemberMessagingOptions.cs | 21 +
.../MemberMessagingWeChatMiniOptions.cs | 46 +
.../Services/IMemberMessageReachAppService.cs | 111 +++
.../Services/IMemberMessageWeChatSender.cs | 17 +
.../Services/MemberMessageReachAppService.cs | 943 ++++++++++++++++++
.../Services/MemberMessageWeChatSender.cs | 155 +++
.../Repositories/IMiniUserRepository.cs | 8 +
.../Entities/MemberMessageTemplate.cs | 36 +
.../Membership/Entities/MemberReachMessage.cs | 96 ++
.../Entities/MemberReachRecipient.cs | 61 ++
.../Enums/MemberMessageAudienceType.cs | 18 +
.../Membership/Enums/MemberMessageChannel.cs | 23 +
.../Enums/MemberMessageRecipientStatus.cs | 23 +
.../Enums/MemberMessageScheduleType.cs | 18 +
.../Membership/Enums/MemberMessageStatus.cs | 33 +
.../Enums/MemberMessageTemplateCategory.cs | 23 +
.../IMemberMessageReachRepository.cs | 125 +++
.../Repositories/IMemberRepository.cs | 8 +
.../App/Persistence/TakeoutAppDbContext.cs | 72 +-
.../EfMemberMessageReachRepository.cs | 296 ++++++
.../App/Repositories/EfMemberRepository.cs | 19 +
.../Persistence/EfMiniUserRepository.cs | 17 +
...60304150000_AddMemberMessageReachModule.cs | 154 +++
33 files changed, 4282 insertions(+), 2 deletions(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Services/MemberMessageReachDispatchJobRunner.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Dto/MemberMessageReachDtos.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/MemberMessageReachMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingOptions.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingWeChatMiniOptions.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageReachAppService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageWeChatSender.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageReachAppService.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageWeChatSender.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberMessageTemplate.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachMessage.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachRecipient.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageAudienceType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageChannel.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageRecipientStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageScheduleType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageTemplateCategory.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberMessageReachRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberMessageReachRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304150000_AddMemberMessageReachModule.cs
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