Merge pull request #2 from msumshk/feature/member-message-reach-module
feat: 完成会员消息触达后端模块
This commit is contained in:
@@ -3,6 +3,7 @@ using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
using TakeoutSaaS.Application.App.Common.Behaviors;
|
||||
using TakeoutSaaS.Application.App.Members.MessageReach.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
@@ -35,6 +36,9 @@ public static class AppApplicationServiceCollectionExtensions
|
||||
// 2. 注册门店模块上下文服务
|
||||
services.AddScoped<StoreContextService>();
|
||||
|
||||
// 3. (空行后) 注册会员消息触达服务
|
||||
services.AddScoped<IMemberMessageReachAppService, MemberMessageReachAppService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user