diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs index 6680599..2bceb20 160000 --- a/TakeoutSaaS.Docs +++ b/TakeoutSaaS.Docs @@ -1 +1 @@ -Subproject commit 66805999120ba0e2df1e3c11100f523e2d3a7fef +Subproject commit 2bceb20baed29fdcc48774b6b65fb9121e806b6f diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs new file mode 100644 index 0000000..2fc6dd2 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberMessageReachContracts.cs @@ -0,0 +1,585 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Member; + +/// +/// 消息触达统计请求。 +/// +public sealed class MemberMessageReachStatsRequest +{ + /// + /// 门店 ID(可选)。 + /// + public string? StoreId { get; set; } +} + +/// +/// 消息列表请求。 +/// +public sealed class MemberMessageReachListRequest +{ + /// + /// 状态过滤(draft/pending/sending/sent/failed)。 + /// + public string? Status { get; set; } + + /// + /// 渠道过滤(inapp/sms/wechat-mini)。 + /// + public string? Channel { get; set; } + + /// + /// 关键词(标题)。 + /// + public string? Keyword { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 10; +} + +/// +/// 消息详情请求。 +/// +public sealed class MemberMessageReachDetailRequest +{ + /// + /// 消息 ID。 + /// + public string MessageId { get; set; } = string.Empty; +} + +/// +/// 保存消息请求。 +/// +public sealed class SaveMemberMessageReachRequest +{ + /// + /// 消息 ID(编辑时传)。 + /// + public string? MessageId { get; set; } + + /// + /// 门店 ID(可选)。 + /// + public string? StoreId { get; set; } + + /// + /// 模板 ID(可选)。 + /// + public string? TemplateId { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 发送渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 目标类型(all/tag)。 + /// + public string AudienceType { get; set; } = "all"; + + /// + /// 目标标签。 + /// + public List AudienceTags { get; set; } = []; + + /// + /// 发送时间类型(immediate/scheduled)。 + /// + public string ScheduleType { get; set; } = "immediate"; + + /// + /// 定时发送时间(UTC 或本地时间,后端统一转 UTC)。 + /// + public DateTime? ScheduledAt { get; set; } + + /// + /// 提交动作(draft/send)。 + /// + public string SubmitAction { get; set; } = "draft"; +} + +/// +/// 删除消息请求。 +/// +public sealed class DeleteMemberMessageReachRequest +{ + /// + /// 消息 ID。 + /// + public string MessageId { get; set; } = string.Empty; +} + +/// +/// 估算人群请求。 +/// +public sealed class MemberMessageAudienceEstimateRequest +{ + /// + /// 目标类型(all/tag)。 + /// + public string AudienceType { get; set; } = "all"; + + /// + /// 标签。 + /// + public List Tags { get; set; } = []; +} + +/// +/// 模板列表请求。 +/// +public sealed class MemberMessageTemplateListRequest +{ + /// + /// 模板分类(marketing/notice/recall)。 + /// + public string? Category { get; set; } + + /// + /// 关键词(模板名称)。 + /// + public string? Keyword { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 10; +} + +/// +/// 模板详情请求。 +/// +public sealed class MemberMessageTemplateDetailRequest +{ + /// + /// 模板 ID。 + /// + public string TemplateId { get; set; } = string.Empty; +} + +/// +/// 保存模板请求。 +/// +public sealed class SaveMemberMessageTemplateRequest +{ + /// + /// 模板 ID(编辑时传)。 + /// + public string? TemplateId { get; set; } + + /// + /// 模板名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 模板分类(marketing/notice/recall)。 + /// + public string Category { get; set; } = "notice"; + + /// + /// 模板内容。 + /// + public string Content { get; set; } = string.Empty; +} + +/// +/// 删除模板请求。 +/// +public sealed class DeleteMemberMessageTemplateRequest +{ + /// + /// 模板 ID。 + /// + public string TemplateId { get; set; } = string.Empty; +} + +/// +/// 消息触达统计响应。 +/// +public sealed class MemberMessageReachStatsResponse +{ + /// + /// 本月发送条数。 + /// + public int MonthlySentCount { get; set; } + + /// + /// 触达人数。 + /// + public int ReachMemberCount { get; set; } + + /// + /// 打开率(百分比)。 + /// + public decimal OpenRate { get; set; } + + /// + /// 转化率(百分比)。 + /// + public decimal ConversionRate { get; set; } +} + +/// +/// 消息列表项响应。 +/// +public sealed class MemberMessageReachListItemResponse +{ + /// + /// 消息 ID。 + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 目标文案。 + /// + public string AudienceText { get; set; } = string.Empty; + + /// + /// 预计触达人数。 + /// + public int EstimatedReachCount { get; set; } + + /// + /// 状态。 + /// + public string Status { get; set; } = string.Empty; + + /// + /// 发送时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? SentAt { get; set; } + + /// + /// 定时发送时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? ScheduledAt { get; set; } + + /// + /// 打开率(百分比)。 + /// + public decimal OpenRate { get; set; } + + /// + /// 转化率(百分比)。 + /// + public decimal ConversionRate { get; set; } +} + +/// +/// 消息列表响应。 +/// +public sealed class MemberMessageReachListResultResponse +{ + /// + /// 列表。 + /// + public List Items { get; set; } = []; + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 总数。 + /// + public int TotalCount { get; set; } +} + +/// +/// 收件明细响应。 +/// +public sealed class MemberMessageReachRecipientResponse +{ + /// + /// 会员 ID。 + /// + public string MemberId { get; set; } = string.Empty; + + /// + /// 渠道。 + /// + public string Channel { get; set; } = string.Empty; + + /// + /// 状态。 + /// + public string Status { get; set; } = string.Empty; + + /// + /// 手机号。 + /// + public string? Mobile { get; set; } + + /// + /// OpenId。 + /// + public string? OpenId { get; set; } + + /// + /// 发送时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? SentAt { get; set; } + + /// + /// 已读时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? ReadAt { get; set; } + + /// + /// 转化时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? ConvertedAt { get; set; } + + /// + /// 错误信息。 + /// + public string? ErrorMessage { get; set; } +} + +/// +/// 消息详情响应。 +/// +public sealed class MemberMessageReachDetailResponse +{ + /// + /// 消息 ID。 + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// 模板 ID。 + /// + public string? TemplateId { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 目标类型。 + /// + public string AudienceType { get; set; } = string.Empty; + + /// + /// 目标标签。 + /// + public List AudienceTags { get; set; } = []; + + /// + /// 目标文案。 + /// + public string AudienceText { get; set; } = string.Empty; + + /// + /// 预计触达人数。 + /// + public int EstimatedReachCount { get; set; } + + /// + /// 发送时间类型。 + /// + public string ScheduleType { get; set; } = string.Empty; + + /// + /// 定时发送时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? ScheduledAt { get; set; } + + /// + /// 发送状态。 + /// + public string Status { get; set; } = string.Empty; + + /// + /// 实际发送时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? SentAt { get; set; } + + /// + /// 成功发送数。 + /// + public int SentCount { get; set; } + + /// + /// 已读数。 + /// + public int ReadCount { get; set; } + + /// + /// 转化数。 + /// + public int ConvertedCount { get; set; } + + /// + /// 打开率(百分比)。 + /// + public decimal OpenRate { get; set; } + + /// + /// 转化率(百分比)。 + /// + public decimal ConversionRate { get; set; } + + /// + /// 错误信息。 + /// + public string? LastError { get; set; } + + /// + /// 收件明细。 + /// + public List Recipients { get; set; } = []; +} + +/// +/// 消息调度元信息响应。 +/// +public sealed class MemberMessageDispatchMetaResponse +{ + /// + /// 消息 ID。 + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// 状态。 + /// + public string Status { get; set; } = string.Empty; + + /// + /// 时间类型。 + /// + public string ScheduleType { get; set; } = string.Empty; + + /// + /// 定时发送时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? ScheduledAt { get; set; } + + /// + /// Hangfire 任务 ID。 + /// + public string? HangfireJobId { get; set; } +} + +/// +/// 模板响应。 +/// +public sealed class MemberMessageTemplateResponse +{ + /// + /// 模板 ID。 + /// + public string TemplateId { get; set; } = string.Empty; + + /// + /// 名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 分类。 + /// + public string Category { get; set; } = string.Empty; + + /// + /// 内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 使用次数。 + /// + public int UsageCount { get; set; } + + /// + /// 最近使用时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string? LastUsedAt { get; set; } +} + +/// +/// 模板列表响应。 +/// +public sealed class MemberMessageTemplateListResultResponse +{ + /// + /// 列表。 + /// + public List Items { get; set; } = []; + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 总数。 + /// + public int TotalCount { get; set; } +} + +/// +/// 目标人群估算响应。 +/// +public sealed class MemberMessageAudienceEstimateResponse +{ + /// + /// 预计触达人数。 + /// + public int ReachCount { get; set; } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs new file mode 100644 index 0000000..caaa250 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberMessageReachController.cs @@ -0,0 +1,428 @@ +using System.Globalization; +using Asp.Versioning; +using Hangfire; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Members.MessageReach.Dto; +using TakeoutSaaS.Application.App.Members.MessageReach.Services; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.TenantApi.Contracts.Member; +using TakeoutSaaS.TenantApi.Services; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 会员消息触达管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/member/message-reach")] +public sealed class MemberMessageReachController( + IMemberMessageReachAppService memberMessageReachAppService, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) + : BaseApiController +{ + private const string ViewPermission = "tenant:member:message-reach:view"; + private const string ManagePermission = "tenant:member:message-reach:manage"; + + /// + /// 获取页面统计。 + /// + [HttpGet("stats")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Stats( + [FromQuery] MemberMessageReachStatsRequest request, + CancellationToken cancellationToken) + { + var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken); + var result = await memberMessageReachAppService.GetStatsAsync(tenantId, cancellationToken); + return ApiResponse.Ok(new MemberMessageReachStatsResponse + { + MonthlySentCount = result.MonthlySentCount, + ReachMemberCount = result.ReachMemberCount, + OpenRate = result.OpenRate, + ConversionRate = result.ConversionRate + }); + } + + /// + /// 分页查询消息列表。 + /// + [HttpGet("list")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> List( + [FromQuery] MemberMessageReachListRequest request, + CancellationToken cancellationToken) + { + var tenantId = ResolveTenantId(); + var result = await memberMessageReachAppService.SearchMessagesAsync( + tenantId, + new SearchMemberMessageInput + { + Status = request.Status, + Channel = request.Channel, + Keyword = request.Keyword, + Page = request.Page, + PageSize = request.PageSize + }, + cancellationToken); + + return ApiResponse.Ok(new MemberMessageReachListResultResponse + { + Items = result.Items.Select(MapMessageListItem).ToList(), + Page = result.Page, + PageSize = result.PageSize, + TotalCount = result.TotalCount + }); + } + + /// + /// 获取消息详情。 + /// + [HttpGet("detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] MemberMessageReachDetailRequest request, + CancellationToken cancellationToken) + { + var tenantId = ResolveTenantId(); + var messageId = StoreApiHelpers.ParseRequiredSnowflake(request.MessageId, nameof(request.MessageId)); + var result = await memberMessageReachAppService.GetMessageDetailAsync(tenantId, messageId, cancellationToken); + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "消息不存在"); + } + + return ApiResponse.Ok(MapMessageDetail(result)); + } + + /// + /// 保存消息(草稿/发送)。 + /// + [HttpPost("save")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Save( + [FromBody] SaveMemberMessageReachRequest request, + CancellationToken cancellationToken) + { + var tenantId = await ResolveTenantIdAsync(request.StoreId, cancellationToken); + var messageId = StoreApiHelpers.ParseSnowflakeOrNull(request.MessageId); + var previousMeta = messageId.HasValue + ? await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, messageId.Value, cancellationToken) + : null; + + var saved = await memberMessageReachAppService.SaveMessageAsync( + tenantId, + new SaveMemberMessageInput + { + MessageId = messageId, + StoreId = StoreApiHelpers.ParseSnowflakeOrNull(request.StoreId), + TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.TemplateId), + Title = request.Title, + Content = request.Content, + Channels = request.Channels, + AudienceType = request.AudienceType, + AudienceTags = request.AudienceTags, + ScheduleType = request.ScheduleType, + ScheduledAt = request.ScheduledAt, + SubmitAction = request.SubmitAction + }, + cancellationToken); + + // 1. 清理旧任务(若存在)。 + if (!string.IsNullOrWhiteSpace(previousMeta?.HangfireJobId)) + { + BackgroundJob.Delete(previousMeta.HangfireJobId); + } + + // 2. 发送动作创建新任务并回写任务 ID。 + if (string.Equals(request.SubmitAction, "send", StringComparison.OrdinalIgnoreCase)) + { + var newJobId = ScheduleDispatchJob(saved.MessageId, saved.ScheduleType, saved.ScheduledAt); + await memberMessageReachAppService.BindDispatchJobAsync(tenantId, saved.MessageId, newJobId, cancellationToken); + } + + // 3. 返回最新调度状态。 + var latest = await memberMessageReachAppService.GetDispatchMetaAsync(tenantId, saved.MessageId, cancellationToken); + return ApiResponse.Ok(MapDispatchMeta(latest ?? saved)); + } + + /// + /// 删除消息。 + /// + [HttpPost("delete")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(null); + } + + /// + /// 估算目标人群。 + /// + [HttpPost("audience/estimate")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(new MemberMessageAudienceEstimateResponse + { + ReachCount = result.ReachCount + }); + } + + /// + /// 分页查询模板。 + /// + [HttpGet("template/list")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(new MemberMessageTemplateListResultResponse + { + Items = result.Items.Select(MapTemplate).ToList(), + Page = result.Page, + PageSize = result.PageSize, + TotalCount = result.TotalCount + }); + } + + /// + /// 获取模板详情。 + /// + [HttpGet("template/detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Error(ErrorCodes.NotFound, "模板不存在"); + } + + return ApiResponse.Ok(MapTemplate(result)); + } + + /// + /// 保存模板。 + /// + [HttpPost("template/save")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(MapTemplate(result)); + } + + /// + /// 删除模板。 + /// + [HttpPost("template/delete")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(null); + } + + private long ResolveTenantId() + { + var (tenantId, _) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + return tenantId; + } + + private async Task 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( + runner => runner.ExecuteAsync(messageId), + delay); + } + + return BackgroundJob.Enqueue(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; + } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Program.cs b/src/Api/TakeoutSaaS.TenantApi/Program.cs index dfd2967..c631c85 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Program.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Program.cs @@ -10,9 +10,12 @@ using Serilog; using StackExchange.Redis; using TakeoutSaaS.Application.App.Common.Geo; 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.Identity.Extensions; using TakeoutSaaS.Application.Messaging.Extensions; +using TakeoutSaaS.Application.Sms.Extensions; using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Infrastructure.App.Extensions; using TakeoutSaaS.Infrastructure.Dictionary.Extensions; @@ -22,6 +25,7 @@ using TakeoutSaaS.Module.Authorization.Extensions; using TakeoutSaaS.Module.Messaging.Extensions; using TakeoutSaaS.Module.Messaging.Options; using TakeoutSaaS.Module.Scheduler.Extensions; +using TakeoutSaaS.Module.Sms.Extensions; using TakeoutSaaS.Module.Storage.Extensions; using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Abstractions.Security; @@ -118,6 +122,7 @@ if (!string.IsNullOrWhiteSpace(redisConn)) // 6. 注册应用层与基础设施(仅租户侧所需) builder.Services.AddAppApplication(); +builder.Services.AddSmsApplication(builder.Configuration); builder.Services.AddIdentityApplication(enableMiniSupport: false); builder.Services.AddAppInfrastructure(builder.Configuration); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false); @@ -132,6 +137,7 @@ builder.Services.AddDictionaryInfrastructure(builder.Configuration); // 9. 注册消息发布能力(未配置 RabbitMQ 时自动降级为 NoOp 实现) builder.Services.AddMessagingApplication(); builder.Services.AddMessagingModule(builder.Configuration); +builder.Services.AddSmsModule(builder.Configuration); builder.Services.AddMassTransit(configurator => { // 注册 SignalR 推送消费者 @@ -167,6 +173,16 @@ builder.Services.AddMassTransit(configurator => builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStorageApplication(); builder.Services.AddSchedulerModule(builder.Configuration); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("MemberMessaging")) + .ValidateDataAnnotations() + .ValidateOnStart(); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.weixin.qq.com/"); + client.Timeout = TimeSpan.FromSeconds(10); +}); +builder.Services.AddScoped(); // 9.1 注册腾讯地图地理编码服务(服务端签名) builder.Services.Configure(builder.Configuration.GetSection(TencentMapOptions.SectionName)); diff --git a/src/Api/TakeoutSaaS.TenantApi/Services/MemberMessageReachDispatchJobRunner.cs b/src/Api/TakeoutSaaS.TenantApi/Services/MemberMessageReachDispatchJobRunner.cs new file mode 100644 index 0000000..e0bd59c --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Services/MemberMessageReachDispatchJobRunner.cs @@ -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; + +/// +/// 会员消息触达发送任务执行器。 +/// +public sealed class MemberMessageReachDispatchJobRunner( + TakeoutAppDbContext dbContext, + ITenantContextAccessor tenantContextAccessor, + IMemberMessageReachAppService memberMessageReachAppService, + ILogger logger) +{ + /// + /// 执行消息发送任务。 + /// + [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); +} diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json index 816f291..fb4b4f8 100644 --- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json @@ -125,6 +125,49 @@ "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": { "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, diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json index 052d290..5f74952 100644 --- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Production.json @@ -123,6 +123,49 @@ "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": { "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, diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs index d2dc801..b1af28d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -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(); + // 3. (空行后) 注册会员消息触达服务 + services.AddScoped(); + return services; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Dto/MemberMessageReachDtos.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Dto/MemberMessageReachDtos.cs new file mode 100644 index 0000000..2d923c3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Dto/MemberMessageReachDtos.cs @@ -0,0 +1,530 @@ +namespace TakeoutSaaS.Application.App.Members.MessageReach.Dto; + +/// +/// 消息触达统计 DTO。 +/// +public sealed class MemberMessageReachStatsDto +{ + /// + /// 本月发送消息条数。 + /// + public int MonthlySentCount { get; init; } + + /// + /// 本月触达人数。 + /// + public int ReachMemberCount { get; init; } + + /// + /// 打开率百分比(0-100)。 + /// + public decimal OpenRate { get; init; } + + /// + /// 转化率百分比(0-100)。 + /// + public decimal ConversionRate { get; init; } +} + +/// +/// 消息列表结果 DTO。 +/// +public sealed class MemberMessageReachListResultDto +{ + /// + /// 列表项。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 总数。 + /// + public int TotalCount { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } +} + +/// +/// 消息列表项 DTO。 +/// +public sealed class MemberMessageReachListItemDto +{ + /// + /// 消息标识。 + /// + public long MessageId { get; init; } + + /// + /// 消息标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 渠道。 + /// + public IReadOnlyList Channels { get; init; } = []; + + /// + /// 目标描述。 + /// + public string AudienceText { get; init; } = string.Empty; + + /// + /// 预计触达人数。 + /// + public int EstimatedReachCount { get; init; } + + /// + /// 发送状态。 + /// + public string Status { get; init; } = string.Empty; + + /// + /// 发送时间(UTC)。 + /// + public DateTime? SentAt { get; init; } + + /// + /// 定时发送时间(UTC)。 + /// + public DateTime? ScheduledAt { get; init; } + + /// + /// 打开率百分比(0-100)。 + /// + public decimal OpenRate { get; init; } + + /// + /// 转化率百分比(0-100)。 + /// + public decimal ConversionRate { get; init; } +} + +/// +/// 消息详情 DTO。 +/// +public sealed class MemberMessageReachDetailDto +{ + /// + /// 消息标识。 + /// + public long MessageId { get; init; } + + /// + /// 模板标识。 + /// + public long? TemplateId { get; init; } + + /// + /// 消息标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 消息正文。 + /// + public string Content { get; init; } = string.Empty; + + /// + /// 渠道。 + /// + public IReadOnlyList Channels { get; init; } = []; + + /// + /// 目标类型。 + /// + public string AudienceType { get; init; } = string.Empty; + + /// + /// 目标标签。 + /// + public IReadOnlyList AudienceTags { get; init; } = []; + + /// + /// 目标描述。 + /// + public string AudienceText { get; init; } = string.Empty; + + /// + /// 预计触达人数。 + /// + public int EstimatedReachCount { get; init; } + + /// + /// 发送时间类型。 + /// + public string ScheduleType { get; init; } = string.Empty; + + /// + /// 定时发送时间(UTC)。 + /// + public DateTime? ScheduledAt { get; init; } + + /// + /// 发送状态。 + /// + public string Status { get; init; } = string.Empty; + + /// + /// 实际发送时间(UTC)。 + /// + public DateTime? SentAt { get; init; } + + /// + /// 发送成功数量。 + /// + public int SentCount { get; init; } + + /// + /// 已读数量。 + /// + public int ReadCount { get; init; } + + /// + /// 转化数量。 + /// + public int ConvertedCount { get; init; } + + /// + /// 打开率百分比(0-100)。 + /// + public decimal OpenRate { get; init; } + + /// + /// 转化率百分比(0-100)。 + /// + public decimal ConversionRate { get; init; } + + /// + /// 最后错误信息。 + /// + public string? LastError { get; init; } + + /// + /// 收件明细。 + /// + public IReadOnlyList Recipients { get; init; } = []; +} + +/// +/// 收件明细 DTO。 +/// +public sealed class MemberMessageReachRecipientDto +{ + /// + /// 会员标识。 + /// + public long MemberId { get; init; } + + /// + /// 渠道。 + /// + public string Channel { get; init; } = string.Empty; + + /// + /// 状态。 + /// + public string Status { get; init; } = string.Empty; + + /// + /// 手机号快照。 + /// + public string? Mobile { get; init; } + + /// + /// OpenId 快照。 + /// + public string? OpenId { get; init; } + + /// + /// 发送时间(UTC)。 + /// + public DateTime? SentAt { get; init; } + + /// + /// 已读时间(UTC)。 + /// + public DateTime? ReadAt { get; init; } + + /// + /// 转化时间(UTC)。 + /// + public DateTime? ConvertedAt { get; init; } + + /// + /// 失败信息。 + /// + public string? ErrorMessage { get; init; } +} + +/// +/// 模板列表结果 DTO。 +/// +public sealed class MemberMessageTemplateListResultDto +{ + /// + /// 列表项。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 总数。 + /// + public int TotalCount { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } +} + +/// +/// 模板 DTO。 +/// +public sealed class MemberMessageTemplateDto +{ + /// + /// 模板标识。 + /// + public long TemplateId { get; init; } + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板分类。 + /// + public string Category { get; init; } = string.Empty; + + /// + /// 模板内容。 + /// + public string Content { get; init; } = string.Empty; + + /// + /// 使用次数。 + /// + public int UsageCount { get; init; } + + /// + /// 最近使用时间(UTC)。 + /// + public DateTime? LastUsedAt { get; init; } +} + +/// +/// 目标人群估算 DTO。 +/// +public sealed class MemberMessageAudienceEstimateDto +{ + /// + /// 预计触达人数。 + /// + public int ReachCount { get; init; } +} + +/// +/// 消息调度元信息 DTO。 +/// +public sealed class MemberMessageDispatchMetaDto +{ + /// + /// 消息标识。 + /// + public long MessageId { get; init; } + + /// + /// 发送状态。 + /// + public string Status { get; init; } = string.Empty; + + /// + /// 发送时间类型。 + /// + public string ScheduleType { get; init; } = string.Empty; + + /// + /// 定时发送时间(UTC)。 + /// + public DateTime? ScheduledAt { get; init; } + + /// + /// Hangfire 任务 ID。 + /// + public string? HangfireJobId { get; init; } +} + +/// +/// 保存消息请求输入。 +/// +public sealed class SaveMemberMessageInput +{ + /// + /// 消息标识。 + /// + public long? MessageId { get; init; } + + /// + /// 门店标识。 + /// + public long? StoreId { get; init; } + + /// + /// 模板标识。 + /// + public long? TemplateId { get; init; } + + /// + /// 标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 内容。 + /// + public string Content { get; init; } = string.Empty; + + /// + /// 渠道。 + /// + public IReadOnlyList Channels { get; init; } = []; + + /// + /// 目标类型。 + /// + public string AudienceType { get; init; } = string.Empty; + + /// + /// 目标标签。 + /// + public IReadOnlyList AudienceTags { get; init; } = []; + + /// + /// 发送时间类型。 + /// + public string ScheduleType { get; init; } = string.Empty; + + /// + /// 定时发送时间(UTC)。 + /// + public DateTime? ScheduledAt { get; init; } + + /// + /// 提交动作(draft/send)。 + /// + public string SubmitAction { get; init; } = "draft"; +} + +/// +/// 搜索消息输入。 +/// +public sealed class SearchMemberMessageInput +{ + /// + /// 状态过滤。 + /// + public string? Status { get; init; } + + /// + /// 渠道过滤。 + /// + public string? Channel { get; init; } + + /// + /// 标题关键词。 + /// + public string? Keyword { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} + +/// +/// 搜索模板输入。 +/// +public sealed class SearchMemberMessageTemplateInput +{ + /// + /// 分类。 + /// + public string? Category { get; init; } + + /// + /// 关键词。 + /// + public string? Keyword { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} + +/// +/// 保存模板输入。 +/// +public sealed class SaveMemberMessageTemplateInput +{ + /// + /// 模板标识。 + /// + public long? TemplateId { get; init; } + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板分类。 + /// + public string Category { get; init; } = string.Empty; + + /// + /// 模板内容。 + /// + public string Content { get; init; } = string.Empty; +} + +/// +/// 估算人群输入。 +/// +public sealed class MemberMessageAudienceEstimateInput +{ + /// + /// 目标类型。 + /// + public string AudienceType { get; init; } = string.Empty; + + /// + /// 标签列表。 + /// + public IReadOnlyList Tags { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/MemberMessageReachMapping.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/MemberMessageReachMapping.cs new file mode 100644 index 0000000..d83db2c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/MemberMessageReachMapping.cs @@ -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 NormalizeTags(IReadOnlyList? 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 NormalizeChannels(IReadOnlyList? 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 source) + { + return JsonSerializer.Serialize(source, JsonOptions); + } + + internal static IReadOnlyList DeserializeStringArray(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(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 + }; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingOptions.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingOptions.cs new file mode 100644 index 0000000..799c4a2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingOptions.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Members.MessageReach.Options; + +/// +/// 会员消息模块配置。 +/// +public sealed class MemberMessagingOptions +{ + /// + /// 会员消息短信场景码。 + /// + [Required] + public string SmsScene { get; set; } = "member_message"; + + /// + /// 微信小程序发送配置。 + /// + [Required] + public MemberMessagingWeChatMiniOptions WeChatMini { get; set; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingWeChatMiniOptions.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingWeChatMiniOptions.cs new file mode 100644 index 0000000..3bfcc4e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Options/MemberMessagingWeChatMiniOptions.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Members.MessageReach.Options; + +/// +/// 微信小程序消息发送配置。 +/// +public sealed class MemberMessagingWeChatMiniOptions +{ + /// + /// 小程序 AppId。 + /// + [Required] + public string AppId { get; set; } = string.Empty; + + /// + /// 小程序 AppSecret。 + /// + [Required] + public string AppSecret { get; set; } = string.Empty; + + /// + /// 订阅消息模板 ID。 + /// + [Required] + public string SubscribeTemplateId { get; set; } = string.Empty; + + /// + /// 小程序跳转页面路径。 + /// + [Required] + public string PagePath { get; set; } = "pages/index/index"; + + /// + /// 标题字段键名。 + /// + [Required] + public string TitleDataKey { get; set; } = "thing1"; + + /// + /// 内容字段键名。 + /// + [Required] + public string ContentDataKey { get; set; } = "thing2"; +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageReachAppService.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageReachAppService.cs new file mode 100644 index 0000000..69c44b1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageReachAppService.cs @@ -0,0 +1,111 @@ +using TakeoutSaaS.Application.App.Members.MessageReach.Dto; + +namespace TakeoutSaaS.Application.App.Members.MessageReach.Services; + +/// +/// 会员消息触达应用服务。 +/// +public interface IMemberMessageReachAppService +{ + /// + /// 获取月度统计。 + /// + Task GetStatsAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 分页查询消息。 + /// + Task SearchMessagesAsync( + long tenantId, + SearchMemberMessageInput input, + CancellationToken cancellationToken = default); + + /// + /// 获取消息详情。 + /// + Task GetMessageDetailAsync( + long tenantId, + long messageId, + CancellationToken cancellationToken = default); + + /// + /// 获取消息调度元信息。 + /// + Task GetDispatchMetaAsync( + long tenantId, + long messageId, + CancellationToken cancellationToken = default); + + /// + /// 保存消息草稿或发送任务。 + /// + Task SaveMessageAsync( + long tenantId, + SaveMemberMessageInput input, + CancellationToken cancellationToken = default); + + /// + /// 绑定消息对应的 Hangfire 任务 ID。 + /// + Task BindDispatchJobAsync( + long tenantId, + long messageId, + string? hangfireJobId, + CancellationToken cancellationToken = default); + + /// + /// 删除消息并返回原任务 ID。 + /// + Task DeleteMessageAsync( + long tenantId, + long messageId, + CancellationToken cancellationToken = default); + + /// + /// 估算目标人群数量。 + /// + Task EstimateAudienceAsync( + long tenantId, + MemberMessageAudienceEstimateInput input, + CancellationToken cancellationToken = default); + + /// + /// 分页查询模板。 + /// + Task SearchTemplatesAsync( + long tenantId, + SearchMemberMessageTemplateInput input, + CancellationToken cancellationToken = default); + + /// + /// 获取模板详情。 + /// + Task GetTemplateAsync( + long tenantId, + long templateId, + CancellationToken cancellationToken = default); + + /// + /// 保存模板。 + /// + Task SaveTemplateAsync( + long tenantId, + SaveMemberMessageTemplateInput input, + CancellationToken cancellationToken = default); + + /// + /// 删除模板。 + /// + Task DeleteTemplateAsync( + long tenantId, + long templateId, + CancellationToken cancellationToken = default); + + /// + /// 执行消息发送。 + /// + Task ExecuteDispatchAsync( + long tenantId, + long messageId, + CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageWeChatSender.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageWeChatSender.cs new file mode 100644 index 0000000..51df2ea --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/IMemberMessageWeChatSender.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Members.MessageReach.Services; + +/// +/// 微信小程序订阅消息发送器。 +/// +public interface IMemberMessageWeChatSender +{ + /// + /// 发送微信订阅消息。 + /// + Task SendAsync( + string openId, + string title, + string content, + CancellationToken cancellationToken = default); +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageReachAppService.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageReachAppService.cs new file mode 100644 index 0000000..20f7b4e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageReachAppService.cs @@ -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; + +/// +/// 会员消息触达应用服务实现。 +/// +public sealed class MemberMessageReachAppService( + IMemberMessageReachRepository memberMessageReachRepository, + IMemberRepository memberRepository, + IMiniUserRepository miniUserRepository, + ISmsSenderResolver smsSenderResolver, + IOptionsMonitor smsOptionsMonitor, + IOptionsMonitor memberMessagingOptionsMonitor, + IMemberMessageWeChatSender memberMessageWeChatSender, + ILogger logger) + : IMemberMessageReachAppService +{ + private static readonly IReadOnlyDictionary AudienceTagAliasMap = BuildAudienceTagAliasMap(); + private static readonly IReadOnlyDictionary AudienceTagDisplayMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["highfrequency"] = "高频客户", + ["newcustomer"] = "新客", + ["dormant"] = "沉睡客户", + ["lost"] = "流失客户", + ["lunchregular"] = "午餐常客", + ["highspend"] = "大额消费" + }; + + private static readonly IReadOnlySet EmptyTagSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + public async Task 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 + }; + } + + /// + public async Task 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 + }; + } + + /// + public async Task 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() + }; + } + + /// + public async Task 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); + } + + /// + public async Task 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); + } + + /// + 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); + } + + /// + public async Task 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; + } + + /// + public async Task 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 + }; + } + + /// + public async Task 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 + }; + } + + /// + public async Task 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); + } + + /// + public async Task 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); + } + + /// + 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); + } + + /// + 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(Math.Max(1, audienceProfiles.Count * channels.Count)); + var errorMessages = new List(); + 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(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> ResolveAudienceProfilesAsync( + long tenantId, + MemberMessageAudienceType audienceType, + IReadOnlyList 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> ResolveMiniUserOpenIdMapAsync( + long tenantId, + IReadOnlyList 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.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 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 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 BuildAudienceTagAliasMap() + { + var map = new Dictionary(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 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]; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageWeChatSender.cs b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageWeChatSender.cs new file mode 100644 index 0000000..d3d1689 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/MessageReach/Services/MemberMessageWeChatSender.cs @@ -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; + +/// +/// 微信小程序订阅消息发送器。 +/// +public sealed class MemberMessageWeChatSender( + HttpClient httpClient, + IDistributedCache cache, + IOptionsMonitor optionsMonitor) + : IMemberMessageWeChatSender +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + /// + 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 + { + ["touser"] = openId.Trim(), + ["template_id"] = options.SubscribeTemplateId, + ["page"] = options.PagePath, + ["data"] = new Dictionary + { + [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(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 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(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; } + } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs index 41594c2..e432c8b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs @@ -23,6 +23,14 @@ public interface IMiniUserRepository /// 小程序用户,如果不存在则返回 null Task FindByIdAsync(long id, CancellationToken cancellationToken = default); + /// + /// 按用户标识集合批量查询小程序用户。 + /// + Task> GetByIdsAsync( + IReadOnlyCollection ids, + long tenantId, + CancellationToken cancellationToken = default); + /// /// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberMessageTemplate.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberMessageTemplate.cs new file mode 100644 index 0000000..35e23f8 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberMessageTemplate.cs @@ -0,0 +1,36 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员消息模板。 +/// +public sealed class MemberMessageTemplate : MultiTenantEntityBase +{ + /// + /// 模板名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 模板分类。 + /// + public MemberMessageTemplateCategory Category { get; set; } = MemberMessageTemplateCategory.Notice; + + /// + /// 模板内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 使用次数。 + /// + public int UsageCount { get; set; } + + /// + /// 最近使用时间(UTC)。 + /// + public DateTime? LastUsedAt { get; set; } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachMessage.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachMessage.cs new file mode 100644 index 0000000..788258d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachMessage.cs @@ -0,0 +1,96 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员消息触达主记录。 +/// +public sealed class MemberReachMessage : MultiTenantEntityBase +{ + /// + /// 门店标识(可空,空表示当前商户全部可见门店)。 + /// + public long? StoreId { get; set; } + + /// + /// 模板标识(可空)。 + /// + public long? TemplateId { get; set; } + + /// + /// 消息标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 消息正文。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 渠道数组 JSON(字符串枚举)。 + /// + public string ChannelsJson { get; set; } = "[]"; + + /// + /// 目标人群类型。 + /// + public MemberMessageAudienceType AudienceType { get; set; } = MemberMessageAudienceType.All; + + /// + /// 目标标签 JSON(字符串数组)。 + /// + public string AudienceTagsJson { get; set; } = "[]"; + + /// + /// 预计触达人数。 + /// + public int EstimatedReachCount { get; set; } + + /// + /// 发送时间类型。 + /// + public MemberMessageScheduleType ScheduleType { get; set; } = MemberMessageScheduleType.Immediate; + + /// + /// 定时发送时间(UTC)。 + /// + public DateTime? ScheduledAt { get; set; } + + /// + /// 状态。 + /// + public MemberMessageStatus Status { get; set; } = MemberMessageStatus.Draft; + + /// + /// 实际发送时间(UTC)。 + /// + public DateTime? SentAt { get; set; } + + /// + /// 实际发送成功数量。 + /// + public int SentCount { get; set; } + + /// + /// 已读数量。 + /// + public int ReadCount { get; set; } + + /// + /// 转化数量。 + /// + public int ConvertedCount { get; set; } + + /// + /// Hangfire 任务 ID。 + /// + public string? HangfireJobId { get; set; } + + /// + /// 最近失败摘要。 + /// + public string? LastError { get; set; } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachRecipient.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachRecipient.cs new file mode 100644 index 0000000..9aa00eb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberReachRecipient.cs @@ -0,0 +1,61 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员消息触达收件明细。 +/// +public sealed class MemberReachRecipient : MultiTenantEntityBase +{ + /// + /// 消息标识。 + /// + public long MessageId { get; set; } + + /// + /// 会员标识。 + /// + public long MemberId { get; set; } + + /// + /// 渠道。 + /// + public MemberMessageChannel Channel { get; set; } = MemberMessageChannel.InApp; + + /// + /// 手机号快照。 + /// + public string? Mobile { get; set; } + + /// + /// 微信 OpenId 快照。 + /// + public string? OpenId { get; set; } + + /// + /// 发送状态。 + /// + public MemberMessageRecipientStatus Status { get; set; } = MemberMessageRecipientStatus.Pending; + + /// + /// 发送时间(UTC)。 + /// + public DateTime? SentAt { get; set; } + + /// + /// 已读时间(UTC)。 + /// + public DateTime? ReadAt { get; set; } + + /// + /// 转化时间(UTC)。 + /// + public DateTime? ConvertedAt { get; set; } + + /// + /// 失败摘要。 + /// + public string? ErrorMessage { get; set; } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageAudienceType.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageAudienceType.cs new file mode 100644 index 0000000..01e3c34 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageAudienceType.cs @@ -0,0 +1,18 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 会员消息目标人群类型。 +/// +public enum MemberMessageAudienceType +{ + /// + /// 全部会员。 + /// + All = 0, + + /// + /// 按标签筛选。 + /// + Tags = 1 +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageChannel.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageChannel.cs new file mode 100644 index 0000000..0dc122f --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageChannel.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 会员消息发送渠道。 +/// +public enum MemberMessageChannel +{ + /// + /// 站内信。 + /// + InApp = 0, + + /// + /// 短信。 + /// + Sms = 1, + + /// + /// 微信小程序订阅消息。 + /// + WeChatMini = 2 +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageRecipientStatus.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageRecipientStatus.cs new file mode 100644 index 0000000..8f9db6e --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageRecipientStatus.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 消息收件明细发送状态。 +/// +public enum MemberMessageRecipientStatus +{ + /// + /// 待发送。 + /// + Pending = 0, + + /// + /// 发送成功。 + /// + Sent = 1, + + /// + /// 发送失败。 + /// + Failed = 2 +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageScheduleType.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageScheduleType.cs new file mode 100644 index 0000000..011bc7e --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageScheduleType.cs @@ -0,0 +1,18 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 消息发送时间类型。 +/// +public enum MemberMessageScheduleType +{ + /// + /// 立即发送。 + /// + Immediate = 0, + + /// + /// 定时发送。 + /// + Scheduled = 1 +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageStatus.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageStatus.cs new file mode 100644 index 0000000..f8eda2c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageStatus.cs @@ -0,0 +1,33 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 会员消息状态。 +/// +public enum MemberMessageStatus +{ + /// + /// 草稿。 + /// + Draft = 0, + + /// + /// 待发送。 + /// + Pending = 1, + + /// + /// 发送中。 + /// + Sending = 2, + + /// + /// 已发送。 + /// + Sent = 3, + + /// + /// 发送失败。 + /// + Failed = 4 +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageTemplateCategory.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageTemplateCategory.cs new file mode 100644 index 0000000..83bca46 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberMessageTemplateCategory.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 消息模板分类。 +/// +public enum MemberMessageTemplateCategory +{ + /// + /// 营销类。 + /// + Marketing = 0, + + /// + /// 通知类。 + /// + Notice = 1, + + /// + /// 召回类。 + /// + Recall = 2 +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberMessageReachRepository.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberMessageReachRepository.cs new file mode 100644 index 0000000..77c3f7b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberMessageReachRepository.cs @@ -0,0 +1,125 @@ +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; + +namespace TakeoutSaaS.Domain.Membership.Repositories; + +/// +/// 会员消息触达仓储。 +/// +public interface IMemberMessageReachRepository +{ + /// + /// 分页查询消息。 + /// + Task<(IReadOnlyList Items, int Total)> SearchMessagesAsync( + long tenantId, + MemberMessageStatus? status, + MemberMessageChannel? channel, + string? keyword, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// 按标识查询消息。 + /// + Task FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default); + + /// + /// 新增消息。 + /// + Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default); + + /// + /// 更新消息。 + /// + Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default); + + /// + /// 删除消息。 + /// + Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default); + + /// + /// 查询消息收件明细。 + /// + Task> GetRecipientsAsync( + long tenantId, + long messageId, + CancellationToken cancellationToken = default); + + /// + /// 删除消息收件明细。 + /// + Task RemoveRecipientsAsync( + long tenantId, + long messageId, + CancellationToken cancellationToken = default); + + /// + /// 批量新增消息收件明细。 + /// + Task AddRecipientsAsync( + IReadOnlyCollection recipients, + CancellationToken cancellationToken = default); + + /// + /// 分页查询模板。 + /// + Task<(IReadOnlyList Items, int Total)> SearchTemplatesAsync( + long tenantId, + MemberMessageTemplateCategory? category, + string? keyword, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// 按标识查询模板。 + /// + Task FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default); + + /// + /// 按名称查询模板(忽略大小写)。 + /// + Task FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default); + + /// + /// 新增模板。 + /// + Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default); + + /// + /// 更新模板。 + /// + Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default); + + /// + /// 删除模板。 + /// + Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default); + + /// + /// 获取月度发送统计。 + /// + Task GetMonthlyStatsAsync( + long tenantId, + DateTime monthStartUtc, + DateTime monthEndUtc, + CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} + +/// +/// 会员消息月度统计快照。 +/// +public sealed record MemberMessageMonthlyStatsSnapshot( + int SentMessageCount, + int ReachMemberCount, + int SentRecipientCount, + int ReadRecipientCount, + int ConvertedRecipientCount); diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs index ee4e272..002bf89 100644 --- a/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IMemberRepository.cs @@ -83,6 +83,14 @@ public interface IMemberRepository long memberProfileId, CancellationToken cancellationToken = default); + /// + /// 按会员集合批量查询标签。 + /// + Task> GetProfileTagsByMemberIdsAsync( + long tenantId, + IReadOnlyCollection memberProfileIds, + CancellationToken cancellationToken = default); + /// /// 替换会员标签集合。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index e042602..b9a9b25 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -422,6 +422,18 @@ public sealed class TakeoutAppDbContext( /// public DbSet MemberStoredCardRechargeRecords => Set(); /// + /// 会员消息触达记录。 + /// + public DbSet MemberReachMessages => Set(); + /// + /// 会员消息模板。 + /// + public DbSet MemberMessageTemplates => Set(); + /// + /// 会员消息触达收件明细。 + /// + public DbSet MemberReachRecipients => Set(); + /// /// 会话记录。 /// public DbSet ChatSessions => Set(); @@ -593,6 +605,9 @@ public sealed class TakeoutAppDbContext( ConfigureMemberPointMallRecord(modelBuilder.Entity()); ConfigureMemberStoredCardPlan(modelBuilder.Entity()); ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity()); + ConfigureMemberReachMessage(modelBuilder.Entity()); + ConfigureMemberMessageTemplate(modelBuilder.Entity()); + ConfigureMemberReachRecipient(modelBuilder.Entity()); ConfigureChatSession(modelBuilder.Entity()); ConfigureChatMessage(modelBuilder.Entity()); ConfigureSupportTicket(modelBuilder.Entity()); @@ -1981,6 +1996,62 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt }); } + private static void ConfigureMemberReachMessage(EntityTypeBuilder 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(); + builder.Property(x => x.AudienceTagsJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.EstimatedReachCount).IsRequired(); + builder.Property(x => x.ScheduleType).HasConversion(); + builder.Property(x => x.ScheduledAt); + builder.Property(x => x.Status).HasConversion(); + 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 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(); + 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 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(); + builder.Property(x => x.Mobile).HasMaxLength(32); + builder.Property(x => x.OpenId).HasMaxLength(128); + builder.Property(x => x.Status).HasConversion(); + 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 builder) { builder.ToTable("chat_sessions"); @@ -2191,4 +2262,3 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt }); } } - diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberMessageReachRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberMessageReachRepository.cs new file mode 100644 index 0000000..3a25644 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberMessageReachRepository.cs @@ -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; + +/// +/// EF 会员消息触达仓储实现。 +/// +public sealed class EfMemberMessageReachRepository(TakeoutAppDbContext context) : IMemberMessageReachRepository +{ + /// + public async Task<(IReadOnlyList 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); + } + + /// + public Task FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default) + { + return context.MemberReachMessages + .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == messageId, cancellationToken); + } + + /// + public Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default) + { + return context.MemberReachMessages.AddAsync(message, cancellationToken).AsTask(); + } + + /// + public Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default) + { + context.MemberReachMessages.Update(message); + return Task.CompletedTask; + } + + /// + public Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default) + { + context.MemberReachMessages.Remove(message); + return Task.CompletedTask; + } + + /// + public async Task> 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); + } + + /// + 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); + } + + /// + public async Task AddRecipientsAsync( + IReadOnlyCollection recipients, + CancellationToken cancellationToken = default) + { + if (recipients.Count == 0) + { + return; + } + + await context.MemberReachRecipients.AddRangeAsync(recipients, cancellationToken); + } + + /// + public async Task<(IReadOnlyList 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); + } + + /// + public Task FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default) + { + return context.MemberMessageTemplates + .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == templateId, cancellationToken); + } + + /// + public Task FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default) + { + var normalizedName = (name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedName)) + { + return Task.FromResult(null); + } + + return context.MemberMessageTemplates + .FirstOrDefaultAsync( + item => + item.TenantId == tenantId && + EF.Functions.ILike(item.Name, normalizedName), + cancellationToken); + } + + /// + public Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default) + { + return context.MemberMessageTemplates.AddAsync(template, cancellationToken).AsTask(); + } + + /// + public Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default) + { + context.MemberMessageTemplates.Update(template); + return Task.CompletedTask; + } + + /// + public Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default) + { + context.MemberMessageTemplates.Remove(template); + return Task.CompletedTask; + } + + /// + public async Task 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); + } + + /// + 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>(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; + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs index d9c6c70..597b701 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMemberRepository.cs @@ -135,6 +135,25 @@ public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRep .ToListAsync(cancellationToken); } + /// + public async Task> GetProfileTagsByMemberIdsAsync( + long tenantId, + IReadOnlyCollection 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); + } + /// public async Task ReplaceProfileTagsAsync( long tenantId, diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs index efb45a4..4d9ad96 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs @@ -27,6 +27,23 @@ public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUse public Task FindByIdAsync(long id, CancellationToken cancellationToken = default) => dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + /// + public async Task> GetByIdsAsync( + IReadOnlyCollection 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); + } + /// /// 创建或更新小程序用户信息。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304150000_AddMemberMessageReachModule.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304150000_AddMemberMessageReachModule.cs new file mode 100644 index 0000000..de39de2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304150000_AddMemberMessageReachModule.cs @@ -0,0 +1,154 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class AddMemberMessageReachModule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "member_message_templates", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "模板名称。"), + Category = table.Column(type: "integer", nullable: false, comment: "模板分类。"), + Content = table.Column(type: "text", nullable: false, comment: "模板内容。"), + UsageCount = table.Column(type: "integer", nullable: false, comment: "使用次数。"), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近使用时间(UTC)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(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(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: true, comment: "门店标识。"), + TemplateId = table.Column(type: "bigint", nullable: true, comment: "模板标识。"), + Title = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "消息标题。"), + Content = table.Column(type: "text", nullable: false, comment: "消息内容。"), + ChannelsJson = table.Column(type: "text", nullable: false, comment: "发送渠道 JSON。"), + AudienceType = table.Column(type: "integer", nullable: false, comment: "目标人群类型。"), + AudienceTagsJson = table.Column(type: "text", nullable: false, comment: "目标标签 JSON。"), + EstimatedReachCount = table.Column(type: "integer", nullable: false, comment: "预计触达人数。"), + ScheduleType = table.Column(type: "integer", nullable: false, comment: "发送时间类型。"), + ScheduledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "定时发送时间(UTC)。"), + Status = table.Column(type: "integer", nullable: false, comment: "消息状态。"), + SentAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "发送时间(UTC)。"), + SentCount = table.Column(type: "integer", nullable: false, comment: "发送成功数量。"), + ReadCount = table.Column(type: "integer", nullable: false, comment: "已读数量。"), + ConvertedCount = table.Column(type: "integer", nullable: false, comment: "转化数量。"), + HangfireJobId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "Hangfire 任务 ID。"), + LastError = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "最后错误信息。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(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(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "bigint", nullable: false, comment: "消息标识。"), + MemberId = table.Column(type: "bigint", nullable: false, comment: "会员标识。"), + Channel = table.Column(type: "integer", nullable: false, comment: "触达渠道。"), + Mobile = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "手机号快照。"), + OpenId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "OpenId 快照。"), + Status = table.Column(type: "integer", nullable: false, comment: "发送状态。"), + SentAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "发送时间(UTC)。"), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "已读时间(UTC)。"), + ConvertedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "转化时间(UTC)。"), + ErrorMessage = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "失败摘要。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "member_message_templates"); + + migrationBuilder.DropTable( + name: "member_reach_messages"); + + migrationBuilder.DropTable( + name: "member_reach_recipients"); + } + } +} +