feat(member): add message reach backend module and docs seeds

This commit is contained in:
2026-03-04 13:35:22 +08:00
parent bd418c5927
commit b5aa060faf
33 changed files with 4282 additions and 2 deletions

View File

@@ -0,0 +1,585 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 消息触达统计请求。
/// </summary>
public sealed class MemberMessageReachStatsRequest
{
/// <summary>
/// 门店 ID可选
/// </summary>
public string? StoreId { get; set; }
}
/// <summary>
/// 消息列表请求。
/// </summary>
public sealed class MemberMessageReachListRequest
{
/// <summary>
/// 状态过滤draft/pending/sending/sent/failed
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 渠道过滤inapp/sms/wechat-mini
/// </summary>
public string? Channel { get; set; }
/// <summary>
/// 关键词(标题)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 消息详情请求。
/// </summary>
public sealed class MemberMessageReachDetailRequest
{
/// <summary>
/// 消息 ID。
/// </summary>
public string MessageId { get; set; } = string.Empty;
}
/// <summary>
/// 保存消息请求。
/// </summary>
public sealed class SaveMemberMessageReachRequest
{
/// <summary>
/// 消息 ID编辑时传
/// </summary>
public string? MessageId { get; set; }
/// <summary>
/// 门店 ID可选
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 模板 ID可选
/// </summary>
public string? TemplateId { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 内容。
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 发送渠道。
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 目标类型all/tag
/// </summary>
public string AudienceType { get; set; } = "all";
/// <summary>
/// 目标标签。
/// </summary>
public List<string> AudienceTags { get; set; } = [];
/// <summary>
/// 发送时间类型immediate/scheduled
/// </summary>
public string ScheduleType { get; set; } = "immediate";
/// <summary>
/// 定时发送时间UTC 或本地时间,后端统一转 UTC
/// </summary>
public DateTime? ScheduledAt { get; set; }
/// <summary>
/// 提交动作draft/send
/// </summary>
public string SubmitAction { get; set; } = "draft";
}
/// <summary>
/// 删除消息请求。
/// </summary>
public sealed class DeleteMemberMessageReachRequest
{
/// <summary>
/// 消息 ID。
/// </summary>
public string MessageId { get; set; } = string.Empty;
}
/// <summary>
/// 估算人群请求。
/// </summary>
public sealed class MemberMessageAudienceEstimateRequest
{
/// <summary>
/// 目标类型all/tag
/// </summary>
public string AudienceType { get; set; } = "all";
/// <summary>
/// 标签。
/// </summary>
public List<string> Tags { get; set; } = [];
}
/// <summary>
/// 模板列表请求。
/// </summary>
public sealed class MemberMessageTemplateListRequest
{
/// <summary>
/// 模板分类marketing/notice/recall
/// </summary>
public string? Category { get; set; }
/// <summary>
/// 关键词(模板名称)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 模板详情请求。
/// </summary>
public sealed class MemberMessageTemplateDetailRequest
{
/// <summary>
/// 模板 ID。
/// </summary>
public string TemplateId { get; set; } = string.Empty;
}
/// <summary>
/// 保存模板请求。
/// </summary>
public sealed class SaveMemberMessageTemplateRequest
{
/// <summary>
/// 模板 ID编辑时传
/// </summary>
public string? TemplateId { get; set; }
/// <summary>
/// 模板名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 模板分类marketing/notice/recall
/// </summary>
public string Category { get; set; } = "notice";
/// <summary>
/// 模板内容。
/// </summary>
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// 删除模板请求。
/// </summary>
public sealed class DeleteMemberMessageTemplateRequest
{
/// <summary>
/// 模板 ID。
/// </summary>
public string TemplateId { get; set; } = string.Empty;
}
/// <summary>
/// 消息触达统计响应。
/// </summary>
public sealed class MemberMessageReachStatsResponse
{
/// <summary>
/// 本月发送条数。
/// </summary>
public int MonthlySentCount { get; set; }
/// <summary>
/// 触达人数。
/// </summary>
public int ReachMemberCount { get; set; }
/// <summary>
/// 打开率(百分比)。
/// </summary>
public decimal OpenRate { get; set; }
/// <summary>
/// 转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; set; }
}
/// <summary>
/// 消息列表项响应。
/// </summary>
public sealed class MemberMessageReachListItemResponse
{
/// <summary>
/// 消息 ID。
/// </summary>
public string MessageId { get; set; } = string.Empty;
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 渠道。
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 目标文案。
/// </summary>
public string AudienceText { get; set; } = string.Empty;
/// <summary>
/// 预计触达人数。
/// </summary>
public int EstimatedReachCount { get; set; }
/// <summary>
/// 状态。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 发送时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? SentAt { get; set; }
/// <summary>
/// 定时发送时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? ScheduledAt { get; set; }
/// <summary>
/// 打开率(百分比)。
/// </summary>
public decimal OpenRate { get; set; }
/// <summary>
/// 转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; set; }
}
/// <summary>
/// 消息列表响应。
/// </summary>
public sealed class MemberMessageReachListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<MemberMessageReachListItemResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总数。
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 收件明细响应。
/// </summary>
public sealed class MemberMessageReachRecipientResponse
{
/// <summary>
/// 会员 ID。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 渠道。
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 状态。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 手机号。
/// </summary>
public string? Mobile { get; set; }
/// <summary>
/// OpenId。
/// </summary>
public string? OpenId { get; set; }
/// <summary>
/// 发送时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? SentAt { get; set; }
/// <summary>
/// 已读时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? ReadAt { get; set; }
/// <summary>
/// 转化时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? ConvertedAt { get; set; }
/// <summary>
/// 错误信息。
/// </summary>
public string? ErrorMessage { get; set; }
}
/// <summary>
/// 消息详情响应。
/// </summary>
public sealed class MemberMessageReachDetailResponse
{
/// <summary>
/// 消息 ID。
/// </summary>
public string MessageId { get; set; } = string.Empty;
/// <summary>
/// 模板 ID。
/// </summary>
public string? TemplateId { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 内容。
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 渠道。
/// </summary>
public List<string> Channels { get; set; } = [];
/// <summary>
/// 目标类型。
/// </summary>
public string AudienceType { get; set; } = string.Empty;
/// <summary>
/// 目标标签。
/// </summary>
public List<string> AudienceTags { get; set; } = [];
/// <summary>
/// 目标文案。
/// </summary>
public string AudienceText { get; set; } = string.Empty;
/// <summary>
/// 预计触达人数。
/// </summary>
public int EstimatedReachCount { get; set; }
/// <summary>
/// 发送时间类型。
/// </summary>
public string ScheduleType { get; set; } = string.Empty;
/// <summary>
/// 定时发送时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? ScheduledAt { get; set; }
/// <summary>
/// 发送状态。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 实际发送时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? SentAt { get; set; }
/// <summary>
/// 成功发送数。
/// </summary>
public int SentCount { get; set; }
/// <summary>
/// 已读数。
/// </summary>
public int ReadCount { get; set; }
/// <summary>
/// 转化数。
/// </summary>
public int ConvertedCount { get; set; }
/// <summary>
/// 打开率(百分比)。
/// </summary>
public decimal OpenRate { get; set; }
/// <summary>
/// 转化率(百分比)。
/// </summary>
public decimal ConversionRate { get; set; }
/// <summary>
/// 错误信息。
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// 收件明细。
/// </summary>
public List<MemberMessageReachRecipientResponse> Recipients { get; set; } = [];
}
/// <summary>
/// 消息调度元信息响应。
/// </summary>
public sealed class MemberMessageDispatchMetaResponse
{
/// <summary>
/// 消息 ID。
/// </summary>
public string MessageId { get; set; } = string.Empty;
/// <summary>
/// 状态。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 时间类型。
/// </summary>
public string ScheduleType { get; set; } = string.Empty;
/// <summary>
/// 定时发送时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? ScheduledAt { get; set; }
/// <summary>
/// Hangfire 任务 ID。
/// </summary>
public string? HangfireJobId { get; set; }
}
/// <summary>
/// 模板响应。
/// </summary>
public sealed class MemberMessageTemplateResponse
{
/// <summary>
/// 模板 ID。
/// </summary>
public string TemplateId { get; set; } = string.Empty;
/// <summary>
/// 名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 分类。
/// </summary>
public string Category { get; set; } = string.Empty;
/// <summary>
/// 内容。
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 使用次数。
/// </summary>
public int UsageCount { get; set; }
/// <summary>
/// 最近使用时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string? LastUsedAt { get; set; }
}
/// <summary>
/// 模板列表响应。
/// </summary>
public sealed class MemberMessageTemplateListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<MemberMessageTemplateResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总数。
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 目标人群估算响应。
/// </summary>
public sealed class MemberMessageAudienceEstimateResponse
{
/// <summary>
/// 预计触达人数。
/// </summary>
public int ReachCount { get; set; }
}

View File

@@ -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;
/// <summary>
/// 会员消息触达管理。
/// </summary>
[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";
/// <summary>
/// 获取页面统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageReachStatsResponse>> Stats(
[FromQuery] MemberMessageReachStatsRequest request,
CancellationToken cancellationToken)
{
var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken);
var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken);
return ApiResponse<MemberMessageReachStatsResponse>.Ok(new MemberMessageReachStatsResponse
{
MonthlySentCount = result.MonthlySentCount,
ReachMemberCount = result.ReachMemberCount,
OpenRate = result.OpenRate,
ConversionRate = result.ConversionRate
});
}
/// <summary>
/// 分页查询消息列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageReachListResultResponse>> 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<MemberMessageReachListResultResponse>.Ok(new MemberMessageReachListResultResponse
{
Items = result.Items.Select(MapMessageListItem).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 获取消息详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageReachDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageReachDetailResponse>> 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<MemberMessageReachDetailResponse>.Error(ErrorCodes.NotFound, "消息不存在");
}
return ApiResponse<MemberMessageReachDetailResponse>.Ok(MapMessageDetail(result));
}
/// <summary>
/// 保存消息(草稿/发送)。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageDispatchMetaResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageDispatchMetaResponse>> 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<MemberMessageDispatchMetaResponse>.Ok(MapDispatchMeta(latest ?? saved));
}
/// <summary>
/// 删除消息。
/// </summary>
[HttpPost("delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeleteMemberMessageReachRequest request,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId));
var oldJobId = await memberMessageReachAppService.DeleteMessageAsync(tenantId, messageId, cancellationToken);
if (!string.IsNullOrWhiteSpace(oldJobId))
{
BackgroundJob.Delete(oldJobId);
}
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 估算目标人群。
/// </summary>
[HttpPost("audience/estimate")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageAudienceEstimateResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageAudienceEstimateResponse>> EstimateAudience(
[FromBody] MemberMessageAudienceEstimateRequest request,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
var result = await memberMessageReachAppService.EstimateAudienceAsync(
tenantId,
new MemberMessageAudienceEstimateInput
{
AudienceType = request.AudienceType,
Tags = request.Tags
},
cancellationToken);
return ApiResponse<MemberMessageAudienceEstimateResponse>.Ok(new MemberMessageAudienceEstimateResponse
{
ReachCount = result.ReachCount
});
}
/// <summary>
/// 分页查询模板。
/// </summary>
[HttpGet("template/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageTemplateListResultResponse>> TemplateList(
[FromQuery] MemberMessageTemplateListRequest request,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
var result = await memberMessageReachAppService.SearchTemplatesAsync(
tenantId,
new SearchMemberMessageTemplateInput
{
Category = request.Category,
Keyword = request.Keyword,
Page = request.Page,
PageSize = request.PageSize
},
cancellationToken);
return ApiResponse<MemberMessageTemplateListResultResponse>.Ok(new MemberMessageTemplateListResultResponse
{
Items = result.Items.Select(MapTemplate).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 获取模板详情。
/// </summary>
[HttpGet("template/detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageTemplateResponse>> TemplateDetail(
[FromQuery] MemberMessageTemplateDetailRequest request,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
var result = await memberMessageReachAppService.GetTemplateAsync(tenantId, templateId, cancellationToken);
if (result is null)
{
return ApiResponse<MemberMessageTemplateResponse>.Error(ErrorCodes.NotFound, "模板不存在");
}
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
}
/// <summary>
/// 保存模板。
/// </summary>
[HttpPost("template/save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberMessageTemplateResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberMessageTemplateResponse>> SaveTemplate(
[FromBody] SaveMemberMessageTemplateRequest request,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
var result = await memberMessageReachAppService.SaveTemplateAsync(
tenantId,
new SaveMemberMessageTemplateInput
{
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId),
Name = request.Name,
Category = request.Category,
Content = request.Content
},
cancellationToken);
return ApiResponse<MemberMessageTemplateResponse>.Ok(MapTemplate(result));
}
/// <summary>
/// 删除模板。
/// </summary>
[HttpPost("template/delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteTemplate(
[FromBody] DeleteMemberMessageTemplateRequest request,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId();
var templateId = StoreApiHelpers.ParseRequiredSnowflake(request.TemplateId, nameof(request.TemplateId));
await memberMessageReachAppService.DeleteTemplateAsync(tenantId, templateId, cancellationToken);
return ApiResponse<object>.Ok(null);
}
private long ResolveTenantId()
{
var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
return tenantId;
}
private async Task<long> ResolveTenantIdAsync(string? storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
if (string.IsNullOrWhiteSpace(storeId))
{
return tenantId;
}
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
await StoreApiHelpers.EnsureStoreAccessibleAsync(
dbContext,
tenantId,
merchantId,
parsedStoreId,
cancellationToken);
return tenantId;
}
private static string ScheduleDispatchJob(long messageId, string scheduleType, DateTime? scheduledAtUtc)
{
if (string.Equals(scheduleType, "scheduled", StringComparison.OrdinalIgnoreCase) && scheduledAtUtc.HasValue)
{
var delay = scheduledAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
if (delay < TimeSpan.Zero)
{
delay = TimeSpan.Zero;
}
return BackgroundJob.Schedule<MemberMessageReachDispatchJobRunner>(
runner => runner.ExecuteAsync(messageId),
delay);
}
return BackgroundJob.Enqueue<MemberMessageReachDispatchJobRunner>(runner => runner.ExecuteAsync(messageId));
}
private static MemberMessageReachListItemResponse MapMessageListItem(MemberMessageReachListItemDto source)
{
return new MemberMessageReachListItemResponse
{
MessageId = source.MessageId.ToString(),
Title = source.Title,
Channels = source.Channels.ToList(),
AudienceText = source.AudienceText,
EstimatedReachCount = source.EstimatedReachCount,
Status = source.Status,
SentAt = FormatDateTime(source.SentAt),
ScheduledAt = FormatDateTime(source.ScheduledAt),
OpenRate = source.OpenRate,
ConversionRate = source.ConversionRate
};
}
private static MemberMessageReachDetailResponse MapMessageDetail(MemberMessageReachDetailDto source)
{
return new MemberMessageReachDetailResponse
{
MessageId = source.MessageId.ToString(),
TemplateId = source.TemplateId?.ToString(),
Title = source.Title,
Content = source.Content,
Channels = source.Channels.ToList(),
AudienceType = source.AudienceType,
AudienceTags = source.AudienceTags.ToList(),
AudienceText = source.AudienceText,
EstimatedReachCount = source.EstimatedReachCount,
ScheduleType = source.ScheduleType,
ScheduledAt = FormatDateTime(source.ScheduledAt),
Status = source.Status,
SentAt = FormatDateTime(source.SentAt),
SentCount = source.SentCount,
ReadCount = source.ReadCount,
ConvertedCount = source.ConvertedCount,
OpenRate = source.OpenRate,
ConversionRate = source.ConversionRate,
LastError = source.LastError,
Recipients = source.Recipients.Select(item => new MemberMessageReachRecipientResponse
{
MemberId = item.MemberId.ToString(),
Channel = item.Channel,
Status = item.Status,
Mobile = item.Mobile,
OpenId = item.OpenId,
SentAt = FormatDateTime(item.SentAt),
ReadAt = FormatDateTime(item.ReadAt),
ConvertedAt = FormatDateTime(item.ConvertedAt),
ErrorMessage = item.ErrorMessage
}).ToList()
};
}
private static MemberMessageDispatchMetaResponse MapDispatchMeta(MemberMessageDispatchMetaDto source)
{
return new MemberMessageDispatchMetaResponse
{
MessageId = source.MessageId.ToString(),
Status = source.Status,
ScheduleType = source.ScheduleType,
ScheduledAt = FormatDateTime(source.ScheduledAt),
HangfireJobId = source.HangfireJobId
};
}
private static MemberMessageTemplateResponse MapTemplate(MemberMessageTemplateDto source)
{
return new MemberMessageTemplateResponse
{
TemplateId = source.TemplateId.ToString(),
Name = source.Name,
Category = source.Category,
Content = source.Content,
UsageCount = source.UsageCount,
LastUsedAt = FormatDateTime(source.LastUsedAt)
};
}
private static string? FormatDateTime(DateTime? value)
{
return value.HasValue
? value.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
: null;
}
}

View File

@@ -10,9 +10,12 @@ using Serilog;
using StackExchange.Redis; using StackExchange.Redis;
using TakeoutSaaS.Application.App.Common.Geo; using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Extensions; using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
using TakeoutSaaS.Application.Dictionary.Extensions; using TakeoutSaaS.Application.Dictionary.Extensions;
using TakeoutSaaS.Application.Identity.Extensions; using TakeoutSaaS.Application.Identity.Extensions;
using TakeoutSaaS.Application.Messaging.Extensions; using TakeoutSaaS.Application.Messaging.Extensions;
using TakeoutSaaS.Application.Sms.Extensions;
using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Infrastructure.App.Extensions; using TakeoutSaaS.Infrastructure.App.Extensions;
using TakeoutSaaS.Infrastructure.Dictionary.Extensions; using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
@@ -22,6 +25,7 @@ using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Messaging.Extensions; using TakeoutSaaS.Module.Messaging.Extensions;
using TakeoutSaaS.Module.Messaging.Options; using TakeoutSaaS.Module.Messaging.Options;
using TakeoutSaaS.Module.Scheduler.Extensions; using TakeoutSaaS.Module.Scheduler.Extensions;
using TakeoutSaaS.Module.Sms.Extensions;
using TakeoutSaaS.Module.Storage.Extensions; using TakeoutSaaS.Module.Storage.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Security;
@@ -118,6 +122,7 @@ if (!string.IsNullOrWhiteSpace(redisConn))
// 6. 注册应用层与基础设施(仅租户侧所需) // 6. 注册应用层与基础设施(仅租户侧所需)
builder.Services.AddAppApplication(); builder.Services.AddAppApplication();
builder.Services.AddSmsApplication(builder.Configuration);
builder.Services.AddIdentityApplication(enableMiniSupport: false); builder.Services.AddIdentityApplication(enableMiniSupport: false);
builder.Services.AddAppInfrastructure(builder.Configuration); builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
@@ -132,6 +137,7 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration);
// 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现) // 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现)
builder.Services.AddMessagingApplication(); builder.Services.AddMessagingApplication();
builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddSmsModule(builder.Configuration);
builder.Services.AddMassTransit(configurator => builder.Services.AddMassTransit(configurator =>
{ {
// 注册 SignalR 推送消费者 // 注册 SignalR 推送消费者
@@ -167,6 +173,16 @@ builder.Services.AddMassTransit(configurator =>
builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStorageModule(builder.Configuration);
builder.Services.AddStorageApplication(); builder.Services.AddStorageApplication();
builder.Services.AddSchedulerModule(builder.Configuration); builder.Services.AddSchedulerModule(builder.Configuration);
builder.Services.AddOptions<MemberMessagingOptions>()
.Bind(builder.Configuration.GetSection("MemberMessaging"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddHttpClient<IMemberMessageWeChatSender, MemberMessageWeChatSender>(client =>
{
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddScoped<MemberMessageReachDispatchJobRunner>();
// 9.1 注册腾讯地图地理编码服务(服务端签名) // 9.1 注册腾讯地图地理编码服务(服务端签名)
builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName)); builder.Services.Configure<TencentMapOptions>(builder.Configuration.GetSection(TencentMapOptions.SectionName));

View File

@@ -0,0 +1,50 @@
using Hangfire;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.TenantApi.Services;
/// <summary>
/// 会员消息触达发送任务执行器。
/// </summary>
public sealed class MemberMessageReachDispatchJobRunner(
TakeoutAppDbContext dbContext,
ITenantContextAccessor tenantContextAccessor,
IMemberMessageReachAppService memberMessageReachAppService,
ILogger<MemberMessageReachDispatchJobRunner> logger)
{
/// <summary>
/// 执行消息发送任务。
/// </summary>
[AutomaticRetry(Attempts = 0)]
public async Task ExecuteAsync(long messageId)
{
// 1. 查询任务所属租户,避免跨租户执行。
var jobMeta = await dbContext.MemberReachMessages
.IgnoreQueryFilters()
.AsNoTracking()
.Where(item => item.Id == messageId)
.Select(item => new JobMeta(item.Id, item.TenantId))
.SingleOrDefaultAsync();
if (jobMeta is null || jobMeta.TenantId <= 0)
{
logger.LogWarning("会员消息任务不存在或租户无效MessageId={MessageId}", messageId);
return;
}
// 2. 切换租户作用域并执行发送逻辑。
using var _ = tenantContextAccessor.EnterTenantScope(jobMeta.TenantId, "scheduler", $"tenant-{jobMeta.TenantId}");
try
{
await memberMessageReachAppService.ExecuteDispatchAsync(jobMeta.TenantId, jobMeta.Id, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "会员消息任务执行失败TenantId={TenantId} MessageId={MessageId}", jobMeta.TenantId, jobMeta.Id);
}
}
private sealed record JobMeta(long Id, long TenantId);
}

View File

@@ -125,6 +125,49 @@
"AntiLeechTokenSecret": "ReplaceWithARandomToken" "AntiLeechTokenSecret": "ReplaceWithARandomToken"
} }
}, },
"Sms": {
"Provider": "Tencent",
"DefaultSignName": "外卖SaaS",
"UseMock": true,
"Tencent": {
"SecretId": "TENCENT_SMS_SECRET_ID",
"SecretKey": "TENCENT_SMS_SECRET_KEY",
"SdkAppId": "1400000000",
"SignName": "外卖SaaS",
"Region": "ap-beijing",
"Endpoint": "https://sms.tencentcloudapi.com"
},
"Aliyun": {
"AccessKeyId": "ALIYUN_SMS_AK",
"AccessKeySecret": "ALIYUN_SMS_SK",
"Endpoint": "dysmsapi.aliyuncs.com",
"SignName": "外卖SaaS",
"Region": "cn-hangzhou"
},
"SceneTemplates": {
"login": "LOGIN_TEMPLATE_ID",
"register": "REGISTER_TEMPLATE_ID",
"reset": "RESET_TEMPLATE_ID",
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
},
"VerificationCode": {
"CodeLength": 6,
"ExpireMinutes": 5,
"CooldownSeconds": 60,
"CachePrefix": "sms:code"
}
},
"MemberMessaging": {
"SmsScene": "member_message",
"WeChatMini": {
"AppId": "WECHAT_MINI_APP_ID",
"AppSecret": "WECHAT_MINI_APP_SECRET",
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
"PagePath": "pages/member/message-center/index",
"TitleDataKey": "thing1",
"ContentDataKey": "thing2"
}
},
"Scheduler": { "Scheduler": {
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"WorkerCount": 10, "WorkerCount": 10,

View File

@@ -123,6 +123,49 @@
"AntiLeechTokenSecret": "ReplaceWithARandomToken" "AntiLeechTokenSecret": "ReplaceWithARandomToken"
} }
}, },
"Sms": {
"Provider": "Tencent",
"DefaultSignName": "外卖SaaS",
"UseMock": false,
"Tencent": {
"SecretId": "TENCENT_SMS_SECRET_ID",
"SecretKey": "TENCENT_SMS_SECRET_KEY",
"SdkAppId": "1400000000",
"SignName": "外卖SaaS",
"Region": "ap-beijing",
"Endpoint": "https://sms.tencentcloudapi.com"
},
"Aliyun": {
"AccessKeyId": "ALIYUN_SMS_AK",
"AccessKeySecret": "ALIYUN_SMS_SK",
"Endpoint": "dysmsapi.aliyuncs.com",
"SignName": "外卖SaaS",
"Region": "cn-hangzhou"
},
"SceneTemplates": {
"login": "LOGIN_TEMPLATE_ID",
"register": "REGISTER_TEMPLATE_ID",
"reset": "RESET_TEMPLATE_ID",
"member_message": "MEMBER_MESSAGE_TEMPLATE_ID"
},
"VerificationCode": {
"CodeLength": 6,
"ExpireMinutes": 5,
"CooldownSeconds": 60,
"CachePrefix": "sms:code"
}
},
"MemberMessaging": {
"SmsScene": "member_message",
"WeChatMini": {
"AppId": "WECHAT_MINI_APP_ID",
"AppSecret": "WECHAT_MINI_APP_SECRET",
"SubscribeTemplateId": "WECHAT_SUBSCRIBE_TEMPLATE_ID",
"PagePath": "pages/member/message-center/index",
"TitleDataKey": "thing1",
"ContentDataKey": "thing2"
}
},
"Scheduler": { "Scheduler": {
"ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "ConnectionString": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
"WorkerCount": 10, "WorkerCount": 10,

View File

@@ -3,6 +3,7 @@ using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Reflection; using System.Reflection;
using TakeoutSaaS.Application.App.Common.Behaviors; using TakeoutSaaS.Application.App.Common.Behaviors;
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
using TakeoutSaaS.Application.App.Personal.Services; using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Application.App.Personal.Validators; using TakeoutSaaS.Application.App.Personal.Validators;
using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Application.App.Stores.Services;
@@ -35,6 +36,9 @@ public static class AppApplicationServiceCollectionExtensions
// 2. 注册门店模块上下文服务 // 2. 注册门店模块上下文服务
services.AddScoped<StoreContextService>(); services.AddScoped<StoreContextService>();
// 3. (空行后) 注册会员消息触达服务
services.AddScoped<IMemberMessageReachAppService, MemberMessageReachAppService>();
return services; return services;
} }
} }

View File

@@ -0,0 +1,530 @@
namespace TakeoutSaaS.Application.App.Members.MessageReach.Dto;
/// <summary>
/// 消息触达统计 DTO。
/// </summary>
public sealed class MemberMessageReachStatsDto
{
/// <summary>
/// 本月发送消息条数。
/// </summary>
public int MonthlySentCount { get; init; }
/// <summary>
/// 本月触达人数。
/// </summary>
public int ReachMemberCount { get; init; }
/// <summary>
/// 打开率百分比0-100
/// </summary>
public decimal OpenRate { get; init; }
/// <summary>
/// 转化率百分比0-100
/// </summary>
public decimal ConversionRate { get; init; }
}
/// <summary>
/// 消息列表结果 DTO。
/// </summary>
public sealed class MemberMessageReachListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<MemberMessageReachListItemDto> Items { get; init; } = [];
/// <summary>
/// 总数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
}
/// <summary>
/// 消息列表项 DTO。
/// </summary>
public sealed class MemberMessageReachListItemDto
{
/// <summary>
/// 消息标识。
/// </summary>
public long MessageId { get; init; }
/// <summary>
/// 消息标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 渠道。
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 目标描述。
/// </summary>
public string AudienceText { get; init; } = string.Empty;
/// <summary>
/// 预计触达人数。
/// </summary>
public int EstimatedReachCount { get; init; }
/// <summary>
/// 发送状态。
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// 发送时间UTC
/// </summary>
public DateTime? SentAt { get; init; }
/// <summary>
/// 定时发送时间UTC
/// </summary>
public DateTime? ScheduledAt { get; init; }
/// <summary>
/// 打开率百分比0-100
/// </summary>
public decimal OpenRate { get; init; }
/// <summary>
/// 转化率百分比0-100
/// </summary>
public decimal ConversionRate { get; init; }
}
/// <summary>
/// 消息详情 DTO。
/// </summary>
public sealed class MemberMessageReachDetailDto
{
/// <summary>
/// 消息标识。
/// </summary>
public long MessageId { get; init; }
/// <summary>
/// 模板标识。
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 消息标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 消息正文。
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// 渠道。
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 目标类型。
/// </summary>
public string AudienceType { get; init; } = string.Empty;
/// <summary>
/// 目标标签。
/// </summary>
public IReadOnlyList<string> AudienceTags { get; init; } = [];
/// <summary>
/// 目标描述。
/// </summary>
public string AudienceText { get; init; } = string.Empty;
/// <summary>
/// 预计触达人数。
/// </summary>
public int EstimatedReachCount { get; init; }
/// <summary>
/// 发送时间类型。
/// </summary>
public string ScheduleType { get; init; } = string.Empty;
/// <summary>
/// 定时发送时间UTC
/// </summary>
public DateTime? ScheduledAt { get; init; }
/// <summary>
/// 发送状态。
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// 实际发送时间UTC
/// </summary>
public DateTime? SentAt { get; init; }
/// <summary>
/// 发送成功数量。
/// </summary>
public int SentCount { get; init; }
/// <summary>
/// 已读数量。
/// </summary>
public int ReadCount { get; init; }
/// <summary>
/// 转化数量。
/// </summary>
public int ConvertedCount { get; init; }
/// <summary>
/// 打开率百分比0-100
/// </summary>
public decimal OpenRate { get; init; }
/// <summary>
/// 转化率百分比0-100
/// </summary>
public decimal ConversionRate { get; init; }
/// <summary>
/// 最后错误信息。
/// </summary>
public string? LastError { get; init; }
/// <summary>
/// 收件明细。
/// </summary>
public IReadOnlyList<MemberMessageReachRecipientDto> Recipients { get; init; } = [];
}
/// <summary>
/// 收件明细 DTO。
/// </summary>
public sealed class MemberMessageReachRecipientDto
{
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 渠道。
/// </summary>
public string Channel { get; init; } = string.Empty;
/// <summary>
/// 状态。
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// 手机号快照。
/// </summary>
public string? Mobile { get; init; }
/// <summary>
/// OpenId 快照。
/// </summary>
public string? OpenId { get; init; }
/// <summary>
/// 发送时间UTC
/// </summary>
public DateTime? SentAt { get; init; }
/// <summary>
/// 已读时间UTC
/// </summary>
public DateTime? ReadAt { get; init; }
/// <summary>
/// 转化时间UTC
/// </summary>
public DateTime? ConvertedAt { get; init; }
/// <summary>
/// 失败信息。
/// </summary>
public string? ErrorMessage { get; init; }
}
/// <summary>
/// 模板列表结果 DTO。
/// </summary>
public sealed class MemberMessageTemplateListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<MemberMessageTemplateDto> Items { get; init; } = [];
/// <summary>
/// 总数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
}
/// <summary>
/// 模板 DTO。
/// </summary>
public sealed class MemberMessageTemplateDto
{
/// <summary>
/// 模板标识。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 模板名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 模板分类。
/// </summary>
public string Category { get; init; } = string.Empty;
/// <summary>
/// 模板内容。
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// 使用次数。
/// </summary>
public int UsageCount { get; init; }
/// <summary>
/// 最近使用时间UTC
/// </summary>
public DateTime? LastUsedAt { get; init; }
}
/// <summary>
/// 目标人群估算 DTO。
/// </summary>
public sealed class MemberMessageAudienceEstimateDto
{
/// <summary>
/// 预计触达人数。
/// </summary>
public int ReachCount { get; init; }
}
/// <summary>
/// 消息调度元信息 DTO。
/// </summary>
public sealed class MemberMessageDispatchMetaDto
{
/// <summary>
/// 消息标识。
/// </summary>
public long MessageId { get; init; }
/// <summary>
/// 发送状态。
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// 发送时间类型。
/// </summary>
public string ScheduleType { get; init; } = string.Empty;
/// <summary>
/// 定时发送时间UTC
/// </summary>
public DateTime? ScheduledAt { get; init; }
/// <summary>
/// Hangfire 任务 ID。
/// </summary>
public string? HangfireJobId { get; init; }
}
/// <summary>
/// 保存消息请求输入。
/// </summary>
public sealed class SaveMemberMessageInput
{
/// <summary>
/// 消息标识。
/// </summary>
public long? MessageId { get; init; }
/// <summary>
/// 门店标识。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 模板标识。
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 内容。
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// 渠道。
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 目标类型。
/// </summary>
public string AudienceType { get; init; } = string.Empty;
/// <summary>
/// 目标标签。
/// </summary>
public IReadOnlyList<string> AudienceTags { get; init; } = [];
/// <summary>
/// 发送时间类型。
/// </summary>
public string ScheduleType { get; init; } = string.Empty;
/// <summary>
/// 定时发送时间UTC
/// </summary>
public DateTime? ScheduledAt { get; init; }
/// <summary>
/// 提交动作draft/send
/// </summary>
public string SubmitAction { get; init; } = "draft";
}
/// <summary>
/// 搜索消息输入。
/// </summary>
public sealed class SearchMemberMessageInput
{
/// <summary>
/// 状态过滤。
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 渠道过滤。
/// </summary>
public string? Channel { get; init; }
/// <summary>
/// 标题关键词。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}
/// <summary>
/// 搜索模板输入。
/// </summary>
public sealed class SearchMemberMessageTemplateInput
{
/// <summary>
/// 分类。
/// </summary>
public string? Category { get; init; }
/// <summary>
/// 关键词。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}
/// <summary>
/// 保存模板输入。
/// </summary>
public sealed class SaveMemberMessageTemplateInput
{
/// <summary>
/// 模板标识。
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 模板名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 模板分类。
/// </summary>
public string Category { get; init; } = string.Empty;
/// <summary>
/// 模板内容。
/// </summary>
public string Content { get; init; } = string.Empty;
}
/// <summary>
/// 估算人群输入。
/// </summary>
public sealed class MemberMessageAudienceEstimateInput
{
/// <summary>
/// 目标类型。
/// </summary>
public string AudienceType { get; init; } = string.Empty;
/// <summary>
/// 标签列表。
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
}

View File

@@ -0,0 +1,260 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Members.MessageReach;
internal static class MemberMessageReachMapping
{
internal static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
internal static MemberMessageAudienceType ParseAudienceType(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"all" => MemberMessageAudienceType.All,
"tag" => MemberMessageAudienceType.Tags,
"tags" => MemberMessageAudienceType.Tags,
_ => throw new BusinessException(ErrorCodes.BadRequest, "audienceType 非法")
};
}
internal static string ToAudienceTypeText(MemberMessageAudienceType value)
{
return value switch
{
MemberMessageAudienceType.All => "all",
MemberMessageAudienceType.Tags => "tag",
_ => "all"
};
}
internal static MemberMessageScheduleType ParseScheduleType(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"immediate" => MemberMessageScheduleType.Immediate,
"scheduled" => MemberMessageScheduleType.Scheduled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "scheduleType 非法")
};
}
internal static string ToScheduleTypeText(MemberMessageScheduleType value)
{
return value switch
{
MemberMessageScheduleType.Immediate => "immediate",
MemberMessageScheduleType.Scheduled => "scheduled",
_ => "immediate"
};
}
internal static MemberMessageStatus ParseStatusOrNull(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"draft" => MemberMessageStatus.Draft,
"pending" => MemberMessageStatus.Pending,
"sending" => MemberMessageStatus.Sending,
"sent" => MemberMessageStatus.Sent,
"failed" => MemberMessageStatus.Failed,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 非法")
};
}
internal static MemberMessageStatus? TryParseStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return ParseStatusOrNull(value);
}
internal static string ToStatusText(MemberMessageStatus value)
{
return value switch
{
MemberMessageStatus.Draft => "draft",
MemberMessageStatus.Pending => "pending",
MemberMessageStatus.Sending => "sending",
MemberMessageStatus.Sent => "sent",
MemberMessageStatus.Failed => "failed",
_ => "draft"
};
}
internal static MemberMessageChannel ParseChannel(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"inapp" => MemberMessageChannel.InApp,
"sms" => MemberMessageChannel.Sms,
"wechat-mini" => MemberMessageChannel.WeChatMini,
"wechat" => MemberMessageChannel.WeChatMini,
_ => throw new BusinessException(ErrorCodes.BadRequest, "channel 非法")
};
}
internal static MemberMessageChannel? TryParseChannel(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return ParseChannel(value);
}
internal static string ToChannelText(MemberMessageChannel value)
{
return value switch
{
MemberMessageChannel.InApp => "inapp",
MemberMessageChannel.Sms => "sms",
MemberMessageChannel.WeChatMini => "wechat-mini",
_ => "inapp"
};
}
internal static string ToRecipientStatusText(MemberMessageRecipientStatus value)
{
return value switch
{
MemberMessageRecipientStatus.Pending => "pending",
MemberMessageRecipientStatus.Sent => "sent",
MemberMessageRecipientStatus.Failed => "failed",
_ => "pending"
};
}
internal static MemberMessageTemplateCategory ParseTemplateCategory(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"marketing" => MemberMessageTemplateCategory.Marketing,
"notice" => MemberMessageTemplateCategory.Notice,
"recall" => MemberMessageTemplateCategory.Recall,
_ => throw new BusinessException(ErrorCodes.BadRequest, "category 非法")
};
}
internal static MemberMessageTemplateCategory? TryParseTemplateCategory(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return ParseTemplateCategory(value);
}
internal static string ToTemplateCategoryText(MemberMessageTemplateCategory value)
{
return value switch
{
MemberMessageTemplateCategory.Marketing => "marketing",
MemberMessageTemplateCategory.Notice => "notice",
MemberMessageTemplateCategory.Recall => "recall",
_ => "notice"
};
}
internal static IReadOnlyList<string> NormalizeTags(IReadOnlyList<string>? tags)
{
return (tags ?? [])
.Select(item => (item ?? string.Empty).Trim())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)
.ToList();
}
internal static IReadOnlyList<string> NormalizeChannels(IReadOnlyList<string>? channels)
{
var parsed = (channels ?? [])
.Select(ParseChannel)
.Distinct()
.ToList();
if (parsed.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
}
return parsed.Select(ToChannelText).ToList();
}
internal static string SerializeStringArray(IReadOnlyList<string> source)
{
return JsonSerializer.Serialize(source, JsonOptions);
}
internal static IReadOnlyList<string> DeserializeStringArray(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(value, JsonOptions)?
.Select(item => (item ?? string.Empty).Trim())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
}
catch
{
return [];
}
}
internal static decimal ResolveRatePercent(int numerator, int denominator)
{
if (denominator <= 0 || numerator <= 0)
{
return 0;
}
return decimal.Round((decimal)numerator * 100m / denominator, 2, MidpointRounding.AwayFromZero);
}
internal static MemberMessageTemplateDto ToTemplateDto(MemberMessageTemplate source)
{
return new MemberMessageTemplateDto
{
TemplateId = source.Id,
Name = source.Name,
Category = ToTemplateCategoryText(source.Category),
Content = source.Content,
UsageCount = source.UsageCount,
LastUsedAt = source.LastUsedAt
};
}
internal static MemberMessageReachRecipientDto ToRecipientDto(MemberReachRecipient source)
{
return new MemberMessageReachRecipientDto
{
MemberId = source.MemberId,
Channel = ToChannelText(source.Channel),
Status = ToRecipientStatusText(source.Status),
Mobile = source.Mobile,
OpenId = source.OpenId,
SentAt = source.SentAt,
ReadAt = source.ReadAt,
ConvertedAt = source.ConvertedAt,
ErrorMessage = source.ErrorMessage
};
}
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Members.MessageReach.Options;
/// <summary>
/// 会员消息模块配置。
/// </summary>
public sealed class MemberMessagingOptions
{
/// <summary>
/// 会员消息短信场景码。
/// </summary>
[Required]
public string SmsScene { get; set; } = "member_message";
/// <summary>
/// 微信小程序发送配置。
/// </summary>
[Required]
public MemberMessagingWeChatMiniOptions WeChatMini { get; set; } = new();
}

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Members.MessageReach.Options;
/// <summary>
/// 微信小程序消息发送配置。
/// </summary>
public sealed class MemberMessagingWeChatMiniOptions
{
/// <summary>
/// 小程序 AppId。
/// </summary>
[Required]
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 小程序 AppSecret。
/// </summary>
[Required]
public string AppSecret { get; set; } = string.Empty;
/// <summary>
/// 订阅消息模板 ID。
/// </summary>
[Required]
public string SubscribeTemplateId { get; set; } = string.Empty;
/// <summary>
/// 小程序跳转页面路径。
/// </summary>
[Required]
public string PagePath { get; set; } = "pages/index/index";
/// <summary>
/// 标题字段键名。
/// </summary>
[Required]
public string TitleDataKey { get; set; } = "thing1";
/// <summary>
/// 内容字段键名。
/// </summary>
[Required]
public string ContentDataKey { get; set; } = "thing2";
}

View File

@@ -0,0 +1,111 @@
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
/// <summary>
/// 会员消息触达应用服务。
/// </summary>
public interface IMemberMessageReachAppService
{
/// <summary>
/// 获取月度统计。
/// </summary>
Task<MemberMessageReachStatsDto> GetStatsAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询消息。
/// </summary>
Task<MemberMessageReachListResultDto> SearchMessagesAsync(
long tenantId,
SearchMemberMessageInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取消息详情。
/// </summary>
Task<MemberMessageReachDetailDto?> GetMessageDetailAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取消息调度元信息。
/// </summary>
Task<MemberMessageDispatchMetaDto?> GetDispatchMetaAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default);
/// <summary>
/// 保存消息草稿或发送任务。
/// </summary>
Task<MemberMessageDispatchMetaDto> SaveMessageAsync(
long tenantId,
SaveMemberMessageInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// 绑定消息对应的 Hangfire 任务 ID。
/// </summary>
Task BindDispatchJobAsync(
long tenantId,
long messageId,
string? hangfireJobId,
CancellationToken cancellationToken = default);
/// <summary>
/// 删除消息并返回原任务 ID。
/// </summary>
Task<string?> DeleteMessageAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default);
/// <summary>
/// 估算目标人群数量。
/// </summary>
Task<MemberMessageAudienceEstimateDto> EstimateAudienceAsync(
long tenantId,
MemberMessageAudienceEstimateInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询模板。
/// </summary>
Task<MemberMessageTemplateListResultDto> SearchTemplatesAsync(
long tenantId,
SearchMemberMessageTemplateInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取模板详情。
/// </summary>
Task<MemberMessageTemplateDto?> GetTemplateAsync(
long tenantId,
long templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// 保存模板。
/// </summary>
Task<MemberMessageTemplateDto> SaveTemplateAsync(
long tenantId,
SaveMemberMessageTemplateInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// 删除模板。
/// </summary>
Task DeleteTemplateAsync(
long tenantId,
long templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// 执行消息发送。
/// </summary>
Task ExecuteDispatchAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
/// <summary>
/// 微信小程序订阅消息发送器。
/// </summary>
public interface IMemberMessageWeChatSender
{
/// <summary>
/// 发送微信订阅消息。
/// </summary>
Task SendAsync(
string openId,
string title,
string content,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,943 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Application.App.Members.MessageReach.Dto;
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Module.Sms.Abstractions;
using TakeoutSaaS.Module.Sms.Models;
using TakeoutSaaS.Module.Sms.Options;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
/// <summary>
/// 会员消息触达应用服务实现。
/// </summary>
public sealed class MemberMessageReachAppService(
IMemberMessageReachRepository memberMessageReachRepository,
IMemberRepository memberRepository,
IMiniUserRepository miniUserRepository,
ISmsSenderResolver smsSenderResolver,
IOptionsMonitor<SmsOptions> smsOptionsMonitor,
IOptionsMonitor<MemberMessagingOptions> memberMessagingOptionsMonitor,
IMemberMessageWeChatSender memberMessageWeChatSender,
ILogger<MemberMessageReachAppService> logger)
: IMemberMessageReachAppService
{
private static readonly IReadOnlyDictionary<string, string> AudienceTagAliasMap = BuildAudienceTagAliasMap();
private static readonly IReadOnlyDictionary<string, string> AudienceTagDisplayMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["highfrequency"] = "高频客户",
["newcustomer"] = "新客",
["dormant"] = "沉睡客户",
["lost"] = "流失客户",
["lunchregular"] = "午餐常客",
["highspend"] = "大额消费"
};
private static readonly IReadOnlySet<string> EmptyTagSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public async Task<MemberMessageReachStatsDto> GetStatsAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 校验租户上下文。
EnsureTenantId(tenantId);
// 2. 读取当月统计快照并计算转化率。
var now = DateTime.UtcNow;
var monthStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var monthEnd = monthStart.AddMonths(1);
var snapshot = await memberMessageReachRepository.GetMonthlyStatsAsync(tenantId, monthStart, monthEnd, cancellationToken);
var openRate = MemberMessageReachMapping.ResolveRatePercent(snapshot.ReadRecipientCount, snapshot.SentRecipientCount);
var conversionRate = MemberMessageReachMapping.ResolveRatePercent(snapshot.ConvertedRecipientCount, snapshot.SentRecipientCount);
// 3. 返回页面统计 DTO。
return new MemberMessageReachStatsDto
{
MonthlySentCount = snapshot.SentMessageCount,
ReachMemberCount = snapshot.ReachMemberCount,
OpenRate = openRate,
ConversionRate = conversionRate
};
}
/// <inheritdoc />
public async Task<MemberMessageReachListResultDto> SearchMessagesAsync(
long tenantId,
SearchMemberMessageInput input,
CancellationToken cancellationToken = default)
{
// 1. 校验租户与查询参数。
EnsureTenantId(tenantId);
var page = input.Page <= 0 ? 1 : input.Page;
var pageSize = Math.Clamp(input.PageSize, 1, 100);
var status = MemberMessageReachMapping.TryParseStatus(input.Status);
var channel = MemberMessageReachMapping.TryParseChannel(input.Channel);
// 2. 调用仓储分页查询。
var (items, total) = await memberMessageReachRepository.SearchMessagesAsync(
tenantId,
status,
channel,
input.Keyword,
page,
pageSize,
cancellationToken);
// 3. 映射分页结果。
return new MemberMessageReachListResultDto
{
Items = items.Select(ToMessageListItem).ToList(),
TotalCount = total,
Page = page,
PageSize = pageSize
};
}
/// <inheritdoc />
public async Task<MemberMessageReachDetailDto?> GetMessageDetailAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default)
{
// 1. 查询主消息记录。
EnsureTenantId(tenantId);
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
if (message is null)
{
return null;
}
// 2. 查询并映射收件明细。
var recipients = await memberMessageReachRepository.GetRecipientsAsync(tenantId, messageId, cancellationToken);
var channels = MemberMessageReachMapping.DeserializeStringArray(message.ChannelsJson);
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(message.AudienceTagsJson);
// 3. 返回详情数据。
return new MemberMessageReachDetailDto
{
MessageId = message.Id,
TemplateId = message.TemplateId,
Title = message.Title,
Content = message.Content,
Channels = channels,
AudienceType = MemberMessageReachMapping.ToAudienceTypeText(message.AudienceType),
AudienceTags = audienceTags,
AudienceText = BuildAudienceText(message.AudienceType, audienceTags, message.EstimatedReachCount),
EstimatedReachCount = message.EstimatedReachCount,
ScheduleType = MemberMessageReachMapping.ToScheduleTypeText(message.ScheduleType),
ScheduledAt = message.ScheduledAt,
Status = MemberMessageReachMapping.ToStatusText(message.Status),
SentAt = message.SentAt,
SentCount = message.SentCount,
ReadCount = message.ReadCount,
ConvertedCount = message.ConvertedCount,
OpenRate = MemberMessageReachMapping.ResolveRatePercent(message.ReadCount, message.SentCount),
ConversionRate = MemberMessageReachMapping.ResolveRatePercent(message.ConvertedCount, message.SentCount),
LastError = message.LastError,
Recipients = recipients.Select(MemberMessageReachMapping.ToRecipientDto).ToList()
};
}
/// <inheritdoc />
public async Task<MemberMessageDispatchMetaDto?> GetDispatchMetaAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default)
{
// 1. 查询消息并返回调度元数据。
EnsureTenantId(tenantId);
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
if (message is null)
{
return null;
}
return ToDispatchMeta(message);
}
/// <inheritdoc />
public async Task<MemberMessageDispatchMetaDto> SaveMessageAsync(
long tenantId,
SaveMemberMessageInput input,
CancellationToken cancellationToken = default)
{
// 1. 入参校验与基础归一化。
EnsureTenantId(tenantId);
var submitAction = NormalizeSubmitAction(input.SubmitAction);
var title = NormalizeRequiredText(input.Title, 128, nameof(input.Title));
var content = NormalizeRequiredText(input.Content, 4096, nameof(input.Content));
var channels = MemberMessageReachMapping.NormalizeChannels(input.Channels);
var audienceType = MemberMessageReachMapping.ParseAudienceType(input.AudienceType);
var audienceTags = MemberMessageReachMapping.NormalizeTags(input.AudienceTags);
var scheduleType = MemberMessageReachMapping.ParseScheduleType(input.ScheduleType);
var scheduledAt = NormalizeScheduledAt(scheduleType, submitAction, input.ScheduledAt);
if (audienceType == MemberMessageAudienceType.Tags && audienceTags.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "按标签筛选时至少选择一个标签");
}
// 2. 估算目标人群并读取/创建消息实体。
var profiles = await ResolveAudienceProfilesAsync(tenantId, audienceType, audienceTags, cancellationToken);
var estimatedReachCount = profiles.Count;
var isNew = !input.MessageId.HasValue;
MemberReachMessage message;
if (isNew)
{
message = new MemberReachMessage
{
TenantId = tenantId
};
await memberMessageReachRepository.AddMessageAsync(message, cancellationToken);
}
else
{
message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, input.MessageId!.Value, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
EnsureMessageEditable(message);
}
// 3. 覆盖消息字段并重置发送态字段。
message.StoreId = input.StoreId;
message.TemplateId = input.TemplateId;
message.Title = title;
message.Content = content;
message.ChannelsJson = MemberMessageReachMapping.SerializeStringArray(channels);
message.AudienceType = audienceType;
message.AudienceTagsJson = MemberMessageReachMapping.SerializeStringArray(audienceTags);
message.EstimatedReachCount = estimatedReachCount;
message.ScheduleType = scheduleType;
message.ScheduledAt = scheduleType == MemberMessageScheduleType.Scheduled ? scheduledAt : null;
message.Status = submitAction == "send" ? MemberMessageStatus.Pending : MemberMessageStatus.Draft;
message.HangfireJobId = null;
message.SentAt = null;
message.SentCount = 0;
message.ReadCount = 0;
message.ConvertedCount = 0;
message.LastError = null;
// 4. 编辑场景清理旧收件记录,确保再次发送时数据一致。
if (!isNew)
{
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, message.Id, cancellationToken);
}
// 5. 持久化并返回调度信息。
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
return ToDispatchMeta(message);
}
/// <inheritdoc />
public async Task BindDispatchJobAsync(
long tenantId,
long messageId,
string? hangfireJobId,
CancellationToken cancellationToken = default)
{
// 1. 查询并绑定任务 ID。
EnsureTenantId(tenantId);
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
message.HangfireJobId = string.IsNullOrWhiteSpace(hangfireJobId)
? null
: Truncate(hangfireJobId.Trim(), 64);
// 2. 保存更新结果。
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<string?> DeleteMessageAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default)
{
// 1. 查询待删除消息并校验状态。
EnsureTenantId(tenantId);
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "消息不存在");
if (message.Status == MemberMessageStatus.Sending)
{
throw new BusinessException(ErrorCodes.BadRequest, "消息发送中,暂不允许删除");
}
// 2. 先删收件明细,再删主记录。
var oldHangfireJobId = message.HangfireJobId;
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, messageId, cancellationToken);
await memberMessageReachRepository.DeleteMessageAsync(message, cancellationToken);
// 3. 持久化删除。
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
return oldHangfireJobId;
}
/// <inheritdoc />
public async Task<MemberMessageAudienceEstimateDto> EstimateAudienceAsync(
long tenantId,
MemberMessageAudienceEstimateInput input,
CancellationToken cancellationToken = default)
{
// 1. 解析目标规则。
EnsureTenantId(tenantId);
var audienceType = MemberMessageReachMapping.ParseAudienceType(input.AudienceType);
var tags = MemberMessageReachMapping.NormalizeTags(input.Tags);
if (audienceType == MemberMessageAudienceType.Tags && tags.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "按标签筛选时至少选择一个标签");
}
// 2. 计算可触达人数。
var profiles = await ResolveAudienceProfilesAsync(tenantId, audienceType, tags, cancellationToken);
return new MemberMessageAudienceEstimateDto
{
ReachCount = profiles.Count
};
}
/// <inheritdoc />
public async Task<MemberMessageTemplateListResultDto> SearchTemplatesAsync(
long tenantId,
SearchMemberMessageTemplateInput input,
CancellationToken cancellationToken = default)
{
// 1. 归一化分页参数。
EnsureTenantId(tenantId);
var page = input.Page <= 0 ? 1 : input.Page;
var pageSize = Math.Clamp(input.PageSize, 1, 100);
var category = MemberMessageReachMapping.TryParseTemplateCategory(input.Category);
// 2. 分页查询模板。
var (items, total) = await memberMessageReachRepository.SearchTemplatesAsync(
tenantId,
category,
input.Keyword,
page,
pageSize,
cancellationToken);
// 3. 映射并返回。
return new MemberMessageTemplateListResultDto
{
Items = items.Select(MemberMessageReachMapping.ToTemplateDto).ToList(),
TotalCount = total,
Page = page,
PageSize = pageSize
};
}
/// <inheritdoc />
public async Task<MemberMessageTemplateDto?> GetTemplateAsync(
long tenantId,
long templateId,
CancellationToken cancellationToken = default)
{
// 1. 查询模板详情。
EnsureTenantId(tenantId);
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, templateId, cancellationToken);
return template is null ? null : MemberMessageReachMapping.ToTemplateDto(template);
}
/// <inheritdoc />
public async Task<MemberMessageTemplateDto> SaveTemplateAsync(
long tenantId,
SaveMemberMessageTemplateInput input,
CancellationToken cancellationToken = default)
{
// 1. 校验并归一化模板入参。
EnsureTenantId(tenantId);
var name = NormalizeRequiredText(input.Name, 64, nameof(input.Name));
var content = NormalizeRequiredText(input.Content, 4096, nameof(input.Content));
var category = MemberMessageReachMapping.ParseTemplateCategory(input.Category);
// 2. 校验同租户模板名称唯一。
var existingTemplateByName = await memberMessageReachRepository.FindTemplateByNameAsync(tenantId, name, cancellationToken);
if (existingTemplateByName is not null && existingTemplateByName.Id != input.TemplateId.GetValueOrDefault())
{
throw new BusinessException(ErrorCodes.BadRequest, "模板名称已存在");
}
// 3. 查询或创建模板实体。
var isNew = !input.TemplateId.HasValue;
MemberMessageTemplate template;
if (isNew)
{
template = new MemberMessageTemplate
{
TenantId = tenantId,
UsageCount = 0
};
await memberMessageReachRepository.AddTemplateAsync(template, cancellationToken);
}
else
{
template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, input.TemplateId!.Value, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "模板不存在");
await memberMessageReachRepository.UpdateTemplateAsync(template, cancellationToken);
}
// 4. 赋值并保存模板。
template.Name = name;
template.Content = content;
template.Category = category;
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
return MemberMessageReachMapping.ToTemplateDto(template);
}
/// <inheritdoc />
public async Task DeleteTemplateAsync(
long tenantId,
long templateId,
CancellationToken cancellationToken = default)
{
// 1. 查询模板并执行删除。
EnsureTenantId(tenantId);
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, templateId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "模板不存在");
await memberMessageReachRepository.DeleteTemplateAsync(template, cancellationToken);
// 2. 持久化删除。
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task ExecuteDispatchAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default)
{
// 1. 查询消息并校验状态。
EnsureTenantId(tenantId);
var message = await memberMessageReachRepository.FindMessageByIdAsync(tenantId, messageId, cancellationToken);
if (message is null)
{
logger.LogWarning("消息发送任务未找到消息记录TenantId={TenantId} MessageId={MessageId}", tenantId, messageId);
return;
}
if (message.Status == MemberMessageStatus.Sending || message.Status == MemberMessageStatus.Sent)
{
logger.LogInformation(
"消息发送任务跳过状态无需发送TenantId={TenantId} MessageId={MessageId} Status={Status}",
tenantId,
messageId,
message.Status);
return;
}
if (message.Status != MemberMessageStatus.Pending)
{
logger.LogWarning(
"消息发送任务跳过状态非待发送TenantId={TenantId} MessageId={MessageId} Status={Status}",
tenantId,
messageId,
message.Status);
return;
}
// 2. 将消息状态推进为发送中并清理旧任务标识。
message.Status = MemberMessageStatus.Sending;
message.HangfireJobId = null;
message.LastError = null;
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
try
{
// 3. 解析目标人群与渠道配置。
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(message.AudienceTagsJson);
var audienceProfiles = await ResolveAudienceProfilesAsync(tenantId, message.AudienceType, audienceTags, cancellationToken);
var channels = MemberMessageReachMapping.DeserializeStringArray(message.ChannelsJson)
.Select(MemberMessageReachMapping.ParseChannel)
.Distinct()
.ToList();
if (channels.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "消息渠道为空,无法执行发送");
}
// 4. 清理旧收件明细并准备渠道所需映射。
await memberMessageReachRepository.RemoveRecipientsAsync(tenantId, messageId, cancellationToken);
var openIdMap = await ResolveMiniUserOpenIdMapAsync(tenantId, audienceProfiles, cancellationToken);
// 5. 按“会员 x 渠道”创建发送明细。
var recipients = new List<MemberReachRecipient>(Math.Max(1, audienceProfiles.Count * channels.Count));
var errorMessages = new List<string>();
foreach (var profile in audienceProfiles)
{
foreach (var channel in channels)
{
var recipient = new MemberReachRecipient
{
TenantId = tenantId,
MessageId = messageId,
MemberId = profile.Id,
Channel = channel,
Status = MemberMessageRecipientStatus.Pending,
Mobile = string.IsNullOrWhiteSpace(profile.Mobile) ? null : profile.Mobile.Trim()
};
try
{
// 5.1 按渠道执行真实发送。
switch (channel)
{
case MemberMessageChannel.InApp:
recipient.Status = MemberMessageRecipientStatus.Sent;
recipient.SentAt = DateTime.UtcNow;
break;
case MemberMessageChannel.Sms:
{
var phone = NormalizePhoneNumber(profile.Mobile);
if (string.IsNullOrWhiteSpace(phone))
{
throw new BusinessException(ErrorCodes.BadRequest, "会员手机号为空");
}
recipient.Mobile = phone;
await SendSmsAsync(phone, message.Title, message.Content, cancellationToken);
recipient.Status = MemberMessageRecipientStatus.Sent;
recipient.SentAt = DateTime.UtcNow;
break;
}
case MemberMessageChannel.WeChatMini:
{
if (!openIdMap.TryGetValue(profile.UserId, out var openId) || string.IsNullOrWhiteSpace(openId))
{
throw new BusinessException(ErrorCodes.BadRequest, "会员未绑定小程序 OpenId");
}
recipient.OpenId = openId;
await memberMessageWeChatSender.SendAsync(openId, message.Title, message.Content, cancellationToken);
recipient.Status = MemberMessageRecipientStatus.Sent;
recipient.SentAt = DateTime.UtcNow;
break;
}
default:
throw new BusinessException(ErrorCodes.BadRequest, "不支持的消息渠道");
}
}
catch (Exception ex)
{
// 5.2 单个收件人发送失败不影响整体流程,保留失败明细。
recipient.Status = MemberMessageRecipientStatus.Failed;
recipient.ErrorMessage = Truncate(CleanupErrorMessage(ex.Message), 512);
errorMessages.Add($"会员{profile.Id}-{MemberMessageReachMapping.ToChannelText(channel)}:{recipient.ErrorMessage}");
}
recipients.Add(recipient);
}
}
// 6. 写入收件明细并回填消息统计。
await memberMessageReachRepository.AddRecipientsAsync(recipients, cancellationToken);
message.EstimatedReachCount = audienceProfiles.Count;
message.SentCount = recipients.Count(item => item.Status == MemberMessageRecipientStatus.Sent);
message.ReadCount = recipients.Count(item => item.ReadAt.HasValue);
message.ConvertedCount = recipients.Count(item => item.ConvertedAt.HasValue);
message.SentAt = DateTime.UtcNow;
message.Status = message.SentCount > 0 ? MemberMessageStatus.Sent : MemberMessageStatus.Failed;
message.LastError = BuildErrorSummary(errorMessages);
// 7. 若使用模板发送,更新模板使用次数。
if (message.TemplateId.HasValue)
{
var template = await memberMessageReachRepository.FindTemplateByIdAsync(tenantId, message.TemplateId.Value, cancellationToken);
if (template is not null)
{
template.UsageCount += 1;
template.LastUsedAt = DateTime.UtcNow;
await memberMessageReachRepository.UpdateTemplateAsync(template, cancellationToken);
}
}
// 8. 保存最终状态。
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
// 9. 全局异常兜底写失败态,并保留错误摘要。
logger.LogError(ex, "执行会员消息发送失败TenantId={TenantId} MessageId={MessageId}", tenantId, messageId);
message.Status = MemberMessageStatus.Failed;
message.SentAt = DateTime.UtcNow;
message.SentCount = 0;
message.ReadCount = 0;
message.ConvertedCount = 0;
message.LastError = Truncate(CleanupErrorMessage(ex.Message), 1024);
await memberMessageReachRepository.UpdateMessageAsync(message, cancellationToken);
await memberMessageReachRepository.SaveChangesAsync(cancellationToken);
}
}
private async Task SendSmsAsync(
string phoneNumber,
string title,
string content,
CancellationToken cancellationToken)
{
// 1. 读取短信模板配置并解析场景模板编码。
var smsOptions = smsOptionsMonitor.CurrentValue;
var messageOptions = memberMessagingOptionsMonitor.CurrentValue;
var smsScene = string.IsNullOrWhiteSpace(messageOptions.SmsScene)
? "member_message"
: messageOptions.SmsScene.Trim();
if (!smsOptions.SceneTemplates.TryGetValue(smsScene, out var templateCode) ||
string.IsNullOrWhiteSpace(templateCode))
{
throw new BusinessException(ErrorCodes.BadRequest, $"未配置短信模板场景:{smsScene}");
}
// 2. 组装变量并调用短信通道发送。
var sender = smsSenderResolver.Resolve();
var variables = new Dictionary<string, string>(StringComparer.Ordinal)
{
["title"] = Truncate(title.Trim(), 20),
["content"] = Truncate(content.Trim(), 64)
};
var request = new SmsSendRequest(phoneNumber, templateCode, variables, smsOptions.DefaultSignName);
var result = await sender.SendAsync(request, cancellationToken);
if (!result.Success)
{
throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{result.Message}");
}
}
private async Task<IReadOnlyList<MemberProfile>> ResolveAudienceProfilesAsync(
long tenantId,
MemberMessageAudienceType audienceType,
IReadOnlyList<string> tags,
CancellationToken cancellationToken)
{
// 1. 获取租户全部会员。
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
if (profiles.Count == 0)
{
return [];
}
// 2. 全量人群直接返回。
if (audienceType == MemberMessageAudienceType.All)
{
return profiles;
}
// 3. 标签人群解析标签规则并构建会员标签映射。
var normalizedInputTags = tags
.Select(ToCanonicalAudienceTag)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedInputTags.Count == 0)
{
return [];
}
var profileIds = profiles.Select(profile => profile.Id).ToList();
var profileTags = await memberRepository.GetProfileTagsByMemberIdsAsync(tenantId, profileIds, cancellationToken);
var profileTagLookup = profileTags
.GroupBy(item => item.MemberProfileId)
.ToDictionary(
group => group.Key,
group => group
.Select(tag => ToCanonicalAudienceTag(tag.TagName))
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.ToHashSet(StringComparer.OrdinalIgnoreCase));
// 4. 应用固定规则筛选目标会员。
var now = DateTime.UtcNow;
var selected = profiles
.Where(profile =>
{
var tagsOfProfile = profileTagLookup.TryGetValue(profile.Id, out var set)
? set
: EmptyTagSet;
return normalizedInputTags.Any(tag => MatchesAudienceTag(profile, tagsOfProfile, tag, now));
})
.ToList();
return selected;
}
private async Task<Dictionary<long, string>> ResolveMiniUserOpenIdMapAsync(
long tenantId,
IReadOnlyList<MemberProfile> profiles,
CancellationToken cancellationToken)
{
// 1. 收集会员关联的小程序用户标识。
var miniUserIds = profiles
.Select(profile => profile.UserId)
.Where(userId => userId > 0)
.Distinct()
.ToList();
if (miniUserIds.Count == 0)
{
return [];
}
// 2. 批量查询并映射 OpenId。
var miniUsers = await miniUserRepository.GetByIdsAsync(miniUserIds, tenantId, cancellationToken);
return miniUsers
.Where(user => !string.IsNullOrWhiteSpace(user.OpenId))
.ToDictionary(user => user.Id, user => user.OpenId, comparer: EqualityComparer<long>.Default);
}
private static MemberMessageReachListItemDto ToMessageListItem(MemberReachMessage source)
{
var channels = MemberMessageReachMapping.DeserializeStringArray(source.ChannelsJson);
var audienceTags = MemberMessageReachMapping.DeserializeStringArray(source.AudienceTagsJson);
return new MemberMessageReachListItemDto
{
MessageId = source.Id,
Title = source.Title,
Channels = channels,
AudienceText = BuildAudienceText(source.AudienceType, audienceTags, source.EstimatedReachCount),
EstimatedReachCount = source.EstimatedReachCount,
Status = MemberMessageReachMapping.ToStatusText(source.Status),
SentAt = source.SentAt,
ScheduledAt = source.ScheduledAt,
OpenRate = MemberMessageReachMapping.ResolveRatePercent(source.ReadCount, source.SentCount),
ConversionRate = MemberMessageReachMapping.ResolveRatePercent(source.ConvertedCount, source.SentCount)
};
}
private static MemberMessageDispatchMetaDto ToDispatchMeta(MemberReachMessage source)
{
return new MemberMessageDispatchMetaDto
{
MessageId = source.Id,
Status = MemberMessageReachMapping.ToStatusText(source.Status),
ScheduleType = MemberMessageReachMapping.ToScheduleTypeText(source.ScheduleType),
ScheduledAt = source.ScheduledAt,
HangfireJobId = source.HangfireJobId
};
}
private static void EnsureTenantId(long tenantId)
{
if (tenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "tenantId 非法");
}
}
private static void EnsureMessageEditable(MemberReachMessage message)
{
if (message.Status == MemberMessageStatus.Sending)
{
throw new BusinessException(ErrorCodes.BadRequest, "消息发送中,暂不允许编辑");
}
if (message.Status == MemberMessageStatus.Sent)
{
throw new BusinessException(ErrorCodes.BadRequest, "已发送消息不允许编辑");
}
}
private static string NormalizeSubmitAction(string? submitAction)
{
var action = (submitAction ?? string.Empty).Trim().ToLowerInvariant();
return action switch
{
"draft" => "draft",
"send" => "send",
_ => throw new BusinessException(ErrorCodes.BadRequest, "submitAction 非法")
};
}
private static string NormalizeRequiredText(string? value, int maxLength, string fieldName)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
if (normalized.Length > maxLength)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
}
return normalized;
}
private static DateTime? NormalizeScheduledAt(
MemberMessageScheduleType scheduleType,
string submitAction,
DateTime? scheduledAt)
{
if (scheduleType == MemberMessageScheduleType.Immediate)
{
return null;
}
if (submitAction == "draft")
{
return scheduledAt?.ToUniversalTime();
}
if (!scheduledAt.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "定时发送必须设置 scheduledAt");
}
var utcTime = scheduledAt.Value.ToUniversalTime();
if (utcTime <= DateTime.UtcNow.AddMinutes(1))
{
throw new BusinessException(ErrorCodes.BadRequest, "定时发送时间必须晚于当前时间 1 分钟");
}
return utcTime;
}
private static bool MatchesAudienceTag(
MemberProfile profile,
IReadOnlySet<string> profileTags,
string targetTag,
DateTime nowUtc)
{
var hasProfileTag = profileTags.Contains(targetTag);
return targetTag switch
{
"newcustomer" => hasProfileTag || profile.JoinedAt >= nowUtc.AddDays(-30),
"dormant" => hasProfileTag || (profile.JoinedAt <= nowUtc.AddDays(-90) && profile.StoredBalance <= 0m && profile.PointsBalance <= 0),
"lost" => hasProfileTag || (profile.JoinedAt <= nowUtc.AddDays(-180) && profile.Status != MemberStatus.Active),
"highspend" => hasProfileTag || profile.StoredRechargeBalance >= 1000m || profile.StoredBalance >= 1000m,
_ => hasProfileTag
};
}
private static string BuildAudienceText(
MemberMessageAudienceType audienceType,
IReadOnlyList<string> tags,
int estimatedReachCount)
{
if (audienceType == MemberMessageAudienceType.All)
{
return "全部会员";
}
var displayTags = tags
.Select(ResolveAudienceDisplayName)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (displayTags.Count == 0)
{
return $"标签人群({estimatedReachCount}人)";
}
return $"{string.Join("", displayTags)}({estimatedReachCount}人)";
}
private static string ResolveAudienceDisplayName(string sourceTag)
{
var canonical = ToCanonicalAudienceTag(sourceTag);
return AudienceTagDisplayMap.TryGetValue(canonical, out var displayName)
? displayName
: (sourceTag ?? string.Empty).Trim();
}
private static string ToCanonicalAudienceTag(string? sourceTag)
{
var normalized = NormalizeAudienceTag(sourceTag);
return AudienceTagAliasMap.TryGetValue(normalized, out var canonical)
? canonical
: normalized;
}
private static string NormalizeAudienceTag(string? sourceTag)
{
if (string.IsNullOrWhiteSpace(sourceTag))
{
return string.Empty;
}
var trimmed = sourceTag.Trim().ToLowerInvariant();
var filtered = trimmed
.Where(ch => ch is not (' ' or '-' or '_'))
.ToArray();
return new string(filtered);
}
private static IReadOnlyDictionary<string, string> BuildAudienceTagAliasMap()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["highfrequency"] = "highfrequency",
["高频客户"] = "highfrequency",
["高频用户"] = "highfrequency",
["高频"] = "highfrequency",
["newcustomer"] = "newcustomer",
["新客"] = "newcustomer",
["新客户"] = "newcustomer",
["dormant"] = "dormant",
["沉睡客户"] = "dormant",
["沉睡用户"] = "dormant",
["lost"] = "lost",
["流失客户"] = "lost",
["流失用户"] = "lost",
["lunchregular"] = "lunchregular",
["午餐常客"] = "lunchregular",
["highspend"] = "highspend",
["大额消费"] = "highspend",
["高消费"] = "highspend"
};
return map;
}
private static string? BuildErrorSummary(IReadOnlyList<string> errors)
{
if (errors.Count == 0)
{
return null;
}
var content = string.Join(" | ", errors.Take(5));
return Truncate(content, 1024);
}
private static string CleanupErrorMessage(string? message)
{
return (message ?? string.Empty)
.Replace('\r', ' ')
.Replace('\n', ' ')
.Trim();
}
private static string? NormalizePhoneNumber(string? mobile)
{
if (string.IsNullOrWhiteSpace(mobile))
{
return null;
}
var trimmed = mobile.Trim();
return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}";
}
private static string Truncate(string? value, int maxLength)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length <= maxLength)
{
return normalized;
}
return normalized[..maxLength];
}
}

View File

@@ -0,0 +1,155 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using TakeoutSaaS.Application.App.Members.MessageReach.Options;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Members.MessageReach.Services;
/// <summary>
/// 微信小程序订阅消息发送器。
/// </summary>
public sealed class MemberMessageWeChatSender(
HttpClient httpClient,
IDistributedCache cache,
IOptionsMonitor<MemberMessagingOptions> optionsMonitor)
: IMemberMessageWeChatSender
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
/// <inheritdoc />
public async Task SendAsync(
string openId,
string title,
string content,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(openId))
{
throw new BusinessException(ErrorCodes.BadRequest, "openId 不能为空");
}
var options = optionsMonitor.CurrentValue.WeChatMini;
var accessToken = await ResolveAccessTokenAsync(options, cancellationToken);
var requestBody = new Dictionary<string, object?>
{
["touser"] = openId.Trim(),
["template_id"] = options.SubscribeTemplateId,
["page"] = options.PagePath,
["data"] = new Dictionary<string, object?>
{
[options.TitleDataKey] = new { value = Truncate(title, 20) },
[options.ContentDataKey] = new { value = Truncate(content, 20) }
}
};
var response = await httpClient.PostAsJsonAsync(
$"cgi-bin/message/subscribe/send?access_token={Uri.EscapeDataString(accessToken)}",
requestBody,
cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<WeChatErrorPayload>(JsonOptions, cancellationToken);
if (payload is null)
{
throw new BusinessException(ErrorCodes.InternalServerError, "微信发送失败:响应为空");
}
if (payload.ErrorCode != 0)
{
throw new BusinessException(
ErrorCodes.InternalServerError,
$"微信发送失败:{payload.ErrorCode} {payload.ErrorMessage}");
}
}
private async Task<string> ResolveAccessTokenAsync(
MemberMessagingWeChatMiniOptions options,
CancellationToken cancellationToken)
{
var cacheKey = $"member-message:wechat:access-token:{options.AppId}";
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (!string.IsNullOrWhiteSpace(cached))
{
return cached;
}
var response = await httpClient.GetAsync(
$"cgi-bin/token?grant_type=client_credential&appid={Uri.EscapeDataString(options.AppId)}&secret={Uri.EscapeDataString(options.AppSecret)}",
cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<WeChatTokenPayload>(JsonOptions, cancellationToken);
if (payload is null)
{
throw new BusinessException(ErrorCodes.InternalServerError, "微信 access_token 获取失败:响应为空");
}
if (payload.ErrorCode != 0)
{
throw new BusinessException(
ErrorCodes.InternalServerError,
$"微信 access_token 获取失败:{payload.ErrorCode} {payload.ErrorMessage}");
}
if (string.IsNullOrWhiteSpace(payload.AccessToken))
{
throw new BusinessException(ErrorCodes.InternalServerError, "微信 access_token 获取失败token 为空");
}
var ttlSeconds = payload.ExpiresIn > 120 ? payload.ExpiresIn - 120 : payload.ExpiresIn;
await cache.SetStringAsync(
cacheKey,
payload.AccessToken,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Math.Max(60, ttlSeconds))
},
cancellationToken);
return payload.AccessToken;
}
private static string Truncate(string? value, int maxLength)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length <= maxLength)
{
return normalized;
}
return normalized[..maxLength];
}
private sealed class WeChatTokenPayload
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("errcode")]
public int ErrorCode { get; set; }
[JsonPropertyName("errmsg")]
public string? ErrorMessage { get; set; }
}
private sealed class WeChatErrorPayload
{
[JsonPropertyName("errcode")]
public int ErrorCode { get; set; }
[JsonPropertyName("errmsg")]
public string? ErrorMessage { get; set; }
}
}

View File

@@ -23,6 +23,14 @@ public interface IMiniUserRepository
/// <returns>小程序用户,如果不存在则返回 null</returns> /// <returns>小程序用户,如果不存在则返回 null</returns>
Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default); Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// 按用户标识集合批量查询小程序用户。
/// </summary>
Task<IReadOnlyList<MiniUser>> GetByIdsAsync(
IReadOnlyCollection<long> ids,
long tenantId,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。 /// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。
/// </summary> /// </summary>

View File

@@ -0,0 +1,36 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员消息模板。
/// </summary>
public sealed class MemberMessageTemplate : MultiTenantEntityBase
{
/// <summary>
/// 模板名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 模板分类。
/// </summary>
public MemberMessageTemplateCategory Category { get; set; } = MemberMessageTemplateCategory.Notice;
/// <summary>
/// 模板内容。
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 使用次数。
/// </summary>
public int UsageCount { get; set; }
/// <summary>
/// 最近使用时间UTC
/// </summary>
public DateTime? LastUsedAt { get; set; }
}

View File

@@ -0,0 +1,96 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员消息触达主记录。
/// </summary>
public sealed class MemberReachMessage : MultiTenantEntityBase
{
/// <summary>
/// 门店标识(可空,空表示当前商户全部可见门店)。
/// </summary>
public long? StoreId { get; set; }
/// <summary>
/// 模板标识(可空)。
/// </summary>
public long? TemplateId { get; set; }
/// <summary>
/// 消息标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 消息正文。
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 渠道数组 JSON字符串枚举
/// </summary>
public string ChannelsJson { get; set; } = "[]";
/// <summary>
/// 目标人群类型。
/// </summary>
public MemberMessageAudienceType AudienceType { get; set; } = MemberMessageAudienceType.All;
/// <summary>
/// 目标标签 JSON字符串数组
/// </summary>
public string AudienceTagsJson { get; set; } = "[]";
/// <summary>
/// 预计触达人数。
/// </summary>
public int EstimatedReachCount { get; set; }
/// <summary>
/// 发送时间类型。
/// </summary>
public MemberMessageScheduleType ScheduleType { get; set; } = MemberMessageScheduleType.Immediate;
/// <summary>
/// 定时发送时间UTC
/// </summary>
public DateTime? ScheduledAt { get; set; }
/// <summary>
/// 状态。
/// </summary>
public MemberMessageStatus Status { get; set; } = MemberMessageStatus.Draft;
/// <summary>
/// 实际发送时间UTC
/// </summary>
public DateTime? SentAt { get; set; }
/// <summary>
/// 实际发送成功数量。
/// </summary>
public int SentCount { get; set; }
/// <summary>
/// 已读数量。
/// </summary>
public int ReadCount { get; set; }
/// <summary>
/// 转化数量。
/// </summary>
public int ConvertedCount { get; set; }
/// <summary>
/// Hangfire 任务 ID。
/// </summary>
public string? HangfireJobId { get; set; }
/// <summary>
/// 最近失败摘要。
/// </summary>
public string? LastError { get; set; }
}

View File

@@ -0,0 +1,61 @@
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Membership.Entities;
/// <summary>
/// 会员消息触达收件明细。
/// </summary>
public sealed class MemberReachRecipient : MultiTenantEntityBase
{
/// <summary>
/// 消息标识。
/// </summary>
public long MessageId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; set; }
/// <summary>
/// 渠道。
/// </summary>
public MemberMessageChannel Channel { get; set; } = MemberMessageChannel.InApp;
/// <summary>
/// 手机号快照。
/// </summary>
public string? Mobile { get; set; }
/// <summary>
/// 微信 OpenId 快照。
/// </summary>
public string? OpenId { get; set; }
/// <summary>
/// 发送状态。
/// </summary>
public MemberMessageRecipientStatus Status { get; set; } = MemberMessageRecipientStatus.Pending;
/// <summary>
/// 发送时间UTC
/// </summary>
public DateTime? SentAt { get; set; }
/// <summary>
/// 已读时间UTC
/// </summary>
public DateTime? ReadAt { get; set; }
/// <summary>
/// 转化时间UTC
/// </summary>
public DateTime? ConvertedAt { get; set; }
/// <summary>
/// 失败摘要。
/// </summary>
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 会员消息目标人群类型。
/// </summary>
public enum MemberMessageAudienceType
{
/// <summary>
/// 全部会员。
/// </summary>
All = 0,
/// <summary>
/// 按标签筛选。
/// </summary>
Tags = 1
}

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 会员消息发送渠道。
/// </summary>
public enum MemberMessageChannel
{
/// <summary>
/// 站内信。
/// </summary>
InApp = 0,
/// <summary>
/// 短信。
/// </summary>
Sms = 1,
/// <summary>
/// 微信小程序订阅消息。
/// </summary>
WeChatMini = 2
}

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 消息收件明细发送状态。
/// </summary>
public enum MemberMessageRecipientStatus
{
/// <summary>
/// 待发送。
/// </summary>
Pending = 0,
/// <summary>
/// 发送成功。
/// </summary>
Sent = 1,
/// <summary>
/// 发送失败。
/// </summary>
Failed = 2
}

View File

@@ -0,0 +1,18 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 消息发送时间类型。
/// </summary>
public enum MemberMessageScheduleType
{
/// <summary>
/// 立即发送。
/// </summary>
Immediate = 0,
/// <summary>
/// 定时发送。
/// </summary>
Scheduled = 1
}

View File

@@ -0,0 +1,33 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 会员消息状态。
/// </summary>
public enum MemberMessageStatus
{
/// <summary>
/// 草稿。
/// </summary>
Draft = 0,
/// <summary>
/// 待发送。
/// </summary>
Pending = 1,
/// <summary>
/// 发送中。
/// </summary>
Sending = 2,
/// <summary>
/// 已发送。
/// </summary>
Sent = 3,
/// <summary>
/// 发送失败。
/// </summary>
Failed = 4
}

View File

@@ -0,0 +1,23 @@
namespace TakeoutSaaS.Domain.Membership.Enums;
/// <summary>
/// 消息模板分类。
/// </summary>
public enum MemberMessageTemplateCategory
{
/// <summary>
/// 营销类。
/// </summary>
Marketing = 0,
/// <summary>
/// 通知类。
/// </summary>
Notice = 1,
/// <summary>
/// 召回类。
/// </summary>
Recall = 2
}

View File

@@ -0,0 +1,125 @@
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
namespace TakeoutSaaS.Domain.Membership.Repositories;
/// <summary>
/// 会员消息触达仓储。
/// </summary>
public interface IMemberMessageReachRepository
{
/// <summary>
/// 分页查询消息。
/// </summary>
Task<(IReadOnlyList<MemberReachMessage> Items, int Total)> SearchMessagesAsync(
long tenantId,
MemberMessageStatus? status,
MemberMessageChannel? channel,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询消息。
/// </summary>
Task<MemberReachMessage?> FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增消息。
/// </summary>
Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// 更新消息。
/// </summary>
Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// 删除消息。
/// </summary>
Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// 查询消息收件明细。
/// </summary>
Task<IReadOnlyList<MemberReachRecipient>> GetRecipientsAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default);
/// <summary>
/// 删除消息收件明细。
/// </summary>
Task RemoveRecipientsAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default);
/// <summary>
/// 批量新增消息收件明细。
/// </summary>
Task AddRecipientsAsync(
IReadOnlyCollection<MemberReachRecipient> recipients,
CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询模板。
/// </summary>
Task<(IReadOnlyList<MemberMessageTemplate> Items, int Total)> SearchTemplatesAsync(
long tenantId,
MemberMessageTemplateCategory? category,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识查询模板。
/// </summary>
Task<MemberMessageTemplate?> FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default);
/// <summary>
/// 按名称查询模板(忽略大小写)。
/// </summary>
Task<MemberMessageTemplate?> FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default);
/// <summary>
/// 新增模板。
/// </summary>
Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default);
/// <summary>
/// 更新模板。
/// </summary>
Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default);
/// <summary>
/// 删除模板。
/// </summary>
Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default);
/// <summary>
/// 获取月度发送统计。
/// </summary>
Task<MemberMessageMonthlyStatsSnapshot> GetMonthlyStatsAsync(
long tenantId,
DateTime monthStartUtc,
DateTime monthEndUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 保存变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 会员消息月度统计快照。
/// </summary>
public sealed record MemberMessageMonthlyStatsSnapshot(
int SentMessageCount,
int ReachMemberCount,
int SentRecipientCount,
int ReadRecipientCount,
int ConvertedRecipientCount);

View File

@@ -83,6 +83,14 @@ public interface IMemberRepository
long memberProfileId, long memberProfileId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 按会员集合批量查询标签。
/// </summary>
Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsByMemberIdsAsync(
long tenantId,
IReadOnlyCollection<long> memberProfileIds,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 替换会员标签集合。 /// 替换会员标签集合。
/// </summary> /// </summary>

View File

@@ -422,6 +422,18 @@ public sealed class TakeoutAppDbContext(
/// </summary> /// </summary>
public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>(); public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>();
/// <summary> /// <summary>
/// 会员消息触达记录。
/// </summary>
public DbSet<MemberReachMessage> MemberReachMessages => Set<MemberReachMessage>();
/// <summary>
/// 会员消息模板。
/// </summary>
public DbSet<MemberMessageTemplate> MemberMessageTemplates => Set<MemberMessageTemplate>();
/// <summary>
/// 会员消息触达收件明细。
/// </summary>
public DbSet<MemberReachRecipient> MemberReachRecipients => Set<MemberReachRecipient>();
/// <summary>
/// 会话记录。 /// 会话记录。
/// </summary> /// </summary>
public DbSet<ChatSession> ChatSessions => Set<ChatSession>(); public DbSet<ChatSession> ChatSessions => Set<ChatSession>();
@@ -593,6 +605,9 @@ public sealed class TakeoutAppDbContext(
ConfigureMemberPointMallRecord(modelBuilder.Entity<MemberPointMallRecord>()); ConfigureMemberPointMallRecord(modelBuilder.Entity<MemberPointMallRecord>());
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>()); ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>()); ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
ConfigureMemberReachMessage(modelBuilder.Entity<MemberReachMessage>());
ConfigureMemberMessageTemplate(modelBuilder.Entity<MemberMessageTemplate>());
ConfigureMemberReachRecipient(modelBuilder.Entity<MemberReachRecipient>());
ConfigureChatSession(modelBuilder.Entity<ChatSession>()); ConfigureChatSession(modelBuilder.Entity<ChatSession>());
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>()); ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>()); ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>());
@@ -1981,6 +1996,62 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt }); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt });
} }
private static void ConfigureMemberReachMessage(EntityTypeBuilder<MemberReachMessage> builder)
{
builder.ToTable("member_reach_messages");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId);
builder.Property(x => x.TemplateId);
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
builder.Property(x => x.ChannelsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.AudienceType).HasConversion<int>();
builder.Property(x => x.AudienceTagsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.EstimatedReachCount).IsRequired();
builder.Property(x => x.ScheduleType).HasConversion<int>();
builder.Property(x => x.ScheduledAt);
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.SentAt);
builder.Property(x => x.SentCount).IsRequired();
builder.Property(x => x.ReadCount).IsRequired();
builder.Property(x => x.ConvertedCount).IsRequired();
builder.Property(x => x.HangfireJobId).HasMaxLength(64);
builder.Property(x => x.LastError).HasMaxLength(1024);
builder.HasIndex(x => new { x.TenantId, x.Status, x.ScheduledAt });
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
}
private static void ConfigureMemberMessageTemplate(EntityTypeBuilder<MemberMessageTemplate> builder)
{
builder.ToTable("member_message_templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Category).HasConversion<int>();
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
builder.Property(x => x.UsageCount).IsRequired();
builder.Property(x => x.LastUsedAt);
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.Category, x.UsageCount });
}
private static void ConfigureMemberReachRecipient(EntityTypeBuilder<MemberReachRecipient> builder)
{
builder.ToTable("member_reach_recipients");
builder.HasKey(x => x.Id);
builder.Property(x => x.MessageId).IsRequired();
builder.Property(x => x.MemberId).IsRequired();
builder.Property(x => x.Channel).HasConversion<int>();
builder.Property(x => x.Mobile).HasMaxLength(32);
builder.Property(x => x.OpenId).HasMaxLength(128);
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.SentAt);
builder.Property(x => x.ReadAt);
builder.Property(x => x.ConvertedAt);
builder.Property(x => x.ErrorMessage).HasMaxLength(512);
builder.HasIndex(x => new { x.TenantId, x.MessageId, x.MemberId, x.Channel }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.MessageId, x.Status });
}
private static void ConfigureChatSession(EntityTypeBuilder<ChatSession> builder) private static void ConfigureChatSession(EntityTypeBuilder<ChatSession> builder)
{ {
builder.ToTable("chat_sessions"); builder.ToTable("chat_sessions");
@@ -2191,4 +2262,3 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt }); builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
} }
} }

View File

@@ -0,0 +1,296 @@
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 会员消息触达仓储实现。
/// </summary>
public sealed class EfMemberMessageReachRepository(TakeoutAppDbContext context) : IMemberMessageReachRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<MemberReachMessage> Items, int Total)> SearchMessagesAsync(
long tenantId,
MemberMessageStatus? status,
MemberMessageChannel? channel,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.MemberReachMessages
.AsNoTracking()
.Where(item => item.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = keyword.Trim();
query = query.Where(item => EF.Functions.ILike(item.Title, $"%{normalizedKeyword}%"));
}
var source = await query
.OrderByDescending(item => item.CreatedAt)
.ThenByDescending(item => item.Id)
.ToListAsync(cancellationToken);
if (channel.HasValue)
{
source = source
.Where(item => HasChannel(item.ChannelsJson, channel.Value))
.ToList();
}
var total = source.Count;
var items = source
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return (items, total);
}
/// <inheritdoc />
public Task<MemberReachMessage?> FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default)
{
return context.MemberReachMessages
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == messageId, cancellationToken);
}
/// <inheritdoc />
public Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
{
return context.MemberReachMessages.AddAsync(message, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
{
context.MemberReachMessages.Update(message);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
{
context.MemberReachMessages.Remove(message);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberReachRecipient>> GetRecipientsAsync(
long tenantId,
long messageId,
CancellationToken cancellationToken = default)
{
return await context.MemberReachRecipients
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.MessageId == messageId)
.OrderBy(item => item.MemberId)
.ThenBy(item => item.Channel)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task RemoveRecipientsAsync(long tenantId, long messageId, CancellationToken cancellationToken = default)
{
var recipients = await context.MemberReachRecipients
.Where(item => item.TenantId == tenantId && item.MessageId == messageId)
.ToListAsync(cancellationToken);
if (recipients.Count == 0)
{
return;
}
context.MemberReachRecipients.RemoveRange(recipients);
}
/// <inheritdoc />
public async Task AddRecipientsAsync(
IReadOnlyCollection<MemberReachRecipient> recipients,
CancellationToken cancellationToken = default)
{
if (recipients.Count == 0)
{
return;
}
await context.MemberReachRecipients.AddRangeAsync(recipients, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<MemberMessageTemplate> Items, int Total)> SearchTemplatesAsync(
long tenantId,
MemberMessageTemplateCategory? category,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.MemberMessageTemplates
.AsNoTracking()
.Where(item => item.TenantId == tenantId);
if (category.HasValue)
{
query = query.Where(item => item.Category == category.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = keyword.Trim();
query = query.Where(item => EF.Functions.ILike(item.Name, $"%{normalizedKeyword}%"));
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(item => item.UsageCount)
.ThenByDescending(item => item.UpdatedAt ?? item.CreatedAt)
.ThenByDescending(item => item.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public Task<MemberMessageTemplate?> FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default)
{
return context.MemberMessageTemplates
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == templateId, cancellationToken);
}
/// <inheritdoc />
public Task<MemberMessageTemplate?> FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default)
{
var normalizedName = (name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
return Task.FromResult<MemberMessageTemplate?>(null);
}
return context.MemberMessageTemplates
.FirstOrDefaultAsync(
item =>
item.TenantId == tenantId &&
EF.Functions.ILike(item.Name, normalizedName),
cancellationToken);
}
/// <inheritdoc />
public Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
{
return context.MemberMessageTemplates.AddAsync(template, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
{
context.MemberMessageTemplates.Update(template);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
{
context.MemberMessageTemplates.Remove(template);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<MemberMessageMonthlyStatsSnapshot> GetMonthlyStatsAsync(
long tenantId,
DateTime monthStartUtc,
DateTime monthEndUtc,
CancellationToken cancellationToken = default)
{
var sentMessageCount = await context.MemberReachMessages
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.Status == MemberMessageStatus.Sent &&
item.SentAt.HasValue &&
item.SentAt.Value >= monthStartUtc &&
item.SentAt.Value < monthEndUtc)
.CountAsync(cancellationToken);
var recipients = await context.MemberReachRecipients
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.SentAt.HasValue &&
item.SentAt.Value >= monthStartUtc &&
item.SentAt.Value < monthEndUtc)
.Select(item => new
{
item.MemberId,
item.Status,
item.ReadAt,
item.ConvertedAt
})
.ToListAsync(cancellationToken);
var reachMemberCount = recipients
.Where(item => item.Status == MemberMessageRecipientStatus.Sent)
.Select(item => item.MemberId)
.Distinct()
.Count();
var sentRecipientCount = recipients.Count(item => item.Status == MemberMessageRecipientStatus.Sent);
var readRecipientCount = recipients.Count(item => item.ReadAt.HasValue);
var convertedRecipientCount = recipients.Count(item => item.ConvertedAt.HasValue);
return new MemberMessageMonthlyStatsSnapshot(
sentMessageCount,
reachMemberCount,
sentRecipientCount,
readRecipientCount,
convertedRecipientCount);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private static bool HasChannel(string channelsJson, MemberMessageChannel channel)
{
if (string.IsNullOrWhiteSpace(channelsJson))
{
return false;
}
try
{
var channels = JsonSerializer.Deserialize<List<string>>(channelsJson) ?? [];
var target = channel switch
{
MemberMessageChannel.InApp => "inapp",
MemberMessageChannel.Sms => "sms",
MemberMessageChannel.WeChatMini => "wechat-mini",
_ => string.Empty
};
if (string.IsNullOrWhiteSpace(target))
{
return false;
}
return channels.Any(item => string.Equals(item, target, StringComparison.OrdinalIgnoreCase));
}
catch
{
return false;
}
}
}

View File

@@ -135,6 +135,25 @@ public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRep
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsByMemberIdsAsync(
long tenantId,
IReadOnlyCollection<long> memberProfileIds,
CancellationToken cancellationToken = default)
{
if (memberProfileIds.Count == 0)
{
return [];
}
return await context.MemberProfileTags
.AsNoTracking()
.Where(x => x.TenantId == tenantId && memberProfileIds.Contains(x.MemberProfileId))
.OrderBy(x => x.MemberProfileId)
.ThenBy(x => x.TagName)
.ToListAsync(cancellationToken);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task ReplaceProfileTagsAsync( public async Task ReplaceProfileTagsAsync(
long tenantId, long tenantId,

View File

@@ -27,6 +27,23 @@ public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUse
public Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default) public Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); => dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<MiniUser>> GetByIdsAsync(
IReadOnlyCollection<long> ids,
long tenantId,
CancellationToken cancellationToken = default)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MiniUsers
.AsNoTracking()
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id))
.ToListAsync(cancellationToken);
}
/// <summary> /// <summary>
/// 创建或更新小程序用户信息。 /// 创建或更新小程序用户信息。
/// </summary> /// </summary>

View File

@@ -0,0 +1,154 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMemberMessageReachModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "member_message_templates",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "模板名称。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "模板分类。"),
Content = table.Column<string>(type: "text", nullable: false, comment: "模板内容。"),
UsageCount = table.Column<int>(type: "integer", nullable: false, comment: "使用次数。"),
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近使用时间UTC。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_message_templates", x => x.Id);
},
comment: "会员消息模板。");
migrationBuilder.CreateTable(
name: "member_reach_messages",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识。"),
TemplateId = table.Column<long>(type: "bigint", nullable: true, comment: "模板标识。"),
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "消息标题。"),
Content = table.Column<string>(type: "text", nullable: false, comment: "消息内容。"),
ChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "发送渠道 JSON。"),
AudienceType = table.Column<int>(type: "integer", nullable: false, comment: "目标人群类型。"),
AudienceTagsJson = table.Column<string>(type: "text", nullable: false, comment: "目标标签 JSON。"),
EstimatedReachCount = table.Column<int>(type: "integer", nullable: false, comment: "预计触达人数。"),
ScheduleType = table.Column<int>(type: "integer", nullable: false, comment: "发送时间类型。"),
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "定时发送时间UTC。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "消息状态。"),
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发送时间UTC。"),
SentCount = table.Column<int>(type: "integer", nullable: false, comment: "发送成功数量。"),
ReadCount = table.Column<int>(type: "integer", nullable: false, comment: "已读数量。"),
ConvertedCount = table.Column<int>(type: "integer", nullable: false, comment: "转化数量。"),
HangfireJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "Hangfire 任务 ID。"),
LastError = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "最后错误信息。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_reach_messages", x => x.Id);
},
comment: "会员消息触达主记录。");
migrationBuilder.CreateTable(
name: "member_reach_recipients",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MessageId = table.Column<long>(type: "bigint", nullable: false, comment: "消息标识。"),
MemberId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
Channel = table.Column<int>(type: "integer", nullable: false, comment: "触达渠道。"),
Mobile = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "手机号快照。"),
OpenId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "OpenId 快照。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "发送状态。"),
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发送时间UTC。"),
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "已读时间UTC。"),
ConvertedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "转化时间UTC。"),
ErrorMessage = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "失败摘要。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_member_reach_recipients", x => x.Id);
},
comment: "会员消息触达收件明细。");
migrationBuilder.CreateIndex(
name: "IX_member_message_templates_TenantId_Category_UsageCount",
table: "member_message_templates",
columns: new[] { "TenantId", "Category", "UsageCount" });
migrationBuilder.CreateIndex(
name: "IX_member_message_templates_TenantId_Name",
table: "member_message_templates",
columns: new[] { "TenantId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_member_reach_messages_TenantId_CreatedAt",
table: "member_reach_messages",
columns: new[] { "TenantId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_member_reach_messages_TenantId_Status_ScheduledAt",
table: "member_reach_messages",
columns: new[] { "TenantId", "Status", "ScheduledAt" });
migrationBuilder.CreateIndex(
name: "IX_member_reach_recipients_TenantId_MessageId_MemberId_Channel",
table: "member_reach_recipients",
columns: new[] { "TenantId", "MessageId", "MemberId", "Channel" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_member_reach_recipients_TenantId_MessageId_Status",
table: "member_reach_recipients",
columns: new[] { "TenantId", "MessageId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "member_message_templates");
migrationBuilder.DropTable(
name: "member_reach_messages");
migrationBuilder.DropTable(
name: "member_reach_recipients");
}
}
}