feat: 完成会员消息触达后端模块

This commit is contained in:
2026-03-04 11:53:52 +08:00
parent 2970134200
commit a8cfda88f7
33 changed files with 4282 additions and 0 deletions

View File

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