Merge pull request #2 from msumshk/feature/member-message-reach-module
feat: 完成会员消息触达后端模块
This commit is contained in:
@@ -52,6 +52,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
||||
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
|
||||
services.AddScoped<IMemberRepository, EfMemberRepository>();
|
||||
services.AddScoped<IMemberMessageReachRepository, EfMemberMessageReachRepository>();
|
||||
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||
|
||||
@@ -410,6 +410,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<MemberStoredCardRechargeRecord> MemberStoredCardRechargeRecords => Set<MemberStoredCardRechargeRecord>();
|
||||
/// <summary>
|
||||
/// 会员消息触达记录。
|
||||
/// </summary>
|
||||
public DbSet<MemberReachMessage> MemberReachMessages => Set<MemberReachMessage>();
|
||||
/// <summary>
|
||||
/// 会员消息模板。
|
||||
/// </summary>
|
||||
public DbSet<MemberMessageTemplate> MemberMessageTemplates => Set<MemberMessageTemplate>();
|
||||
/// <summary>
|
||||
/// 会员消息触达收件明细。
|
||||
/// </summary>
|
||||
public DbSet<MemberReachRecipient> MemberReachRecipients => Set<MemberReachRecipient>();
|
||||
/// <summary>
|
||||
/// 会话记录。
|
||||
/// </summary>
|
||||
public DbSet<ChatSession> ChatSessions => Set<ChatSession>();
|
||||
@@ -578,6 +590,9 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
||||
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
|
||||
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
|
||||
ConfigureMemberReachMessage(modelBuilder.Entity<MemberReachMessage>());
|
||||
ConfigureMemberMessageTemplate(modelBuilder.Entity<MemberMessageTemplate>());
|
||||
ConfigureMemberReachRecipient(modelBuilder.Entity<MemberReachRecipient>());
|
||||
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
|
||||
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
|
||||
ConfigureSupportTicket(modelBuilder.Entity<SupportTicket>());
|
||||
@@ -1892,6 +1907,62 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RechargedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberReachMessage(EntityTypeBuilder<MemberReachMessage> builder)
|
||||
{
|
||||
builder.ToTable("member_reach_messages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.StoreId);
|
||||
builder.Property(x => x.TemplateId);
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.ChannelsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.AudienceType).HasConversion<int>();
|
||||
builder.Property(x => x.AudienceTagsJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.EstimatedReachCount).IsRequired();
|
||||
builder.Property(x => x.ScheduleType).HasConversion<int>();
|
||||
builder.Property(x => x.ScheduledAt);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.SentAt);
|
||||
builder.Property(x => x.SentCount).IsRequired();
|
||||
builder.Property(x => x.ReadCount).IsRequired();
|
||||
builder.Property(x => x.ConvertedCount).IsRequired();
|
||||
builder.Property(x => x.HangfireJobId).HasMaxLength(64);
|
||||
builder.Property(x => x.LastError).HasMaxLength(1024);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status, x.ScheduledAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberMessageTemplate(EntityTypeBuilder<MemberMessageTemplate> builder)
|
||||
{
|
||||
builder.ToTable("member_message_templates");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Category).HasConversion<int>();
|
||||
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UsageCount).IsRequired();
|
||||
builder.Property(x => x.LastUsedAt);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.Category, x.UsageCount });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberReachRecipient(EntityTypeBuilder<MemberReachRecipient> builder)
|
||||
{
|
||||
builder.ToTable("member_reach_recipients");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.MessageId).IsRequired();
|
||||
builder.Property(x => x.MemberId).IsRequired();
|
||||
builder.Property(x => x.Channel).HasConversion<int>();
|
||||
builder.Property(x => x.Mobile).HasMaxLength(32);
|
||||
builder.Property(x => x.OpenId).HasMaxLength(128);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.SentAt);
|
||||
builder.Property(x => x.ReadAt);
|
||||
builder.Property(x => x.ConvertedAt);
|
||||
builder.Property(x => x.ErrorMessage).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MessageId, x.MemberId, x.Channel }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.MessageId, x.Status });
|
||||
}
|
||||
|
||||
private static void ConfigureChatSession(EntityTypeBuilder<ChatSession> builder)
|
||||
{
|
||||
builder.ToTable("chat_sessions");
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Domain.Membership.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 会员消息触达仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMemberMessageReachRepository(TakeoutAppDbContext context) : IMemberMessageReachRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<MemberReachMessage> Items, int Total)> SearchMessagesAsync(
|
||||
long tenantId,
|
||||
MemberMessageStatus? status,
|
||||
MemberMessageChannel? channel,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.MemberReachMessages
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Status == status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(item => EF.Functions.ILike(item.Title, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
var source = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channel.HasValue)
|
||||
{
|
||||
source = source
|
||||
.Where(item => HasChannel(item.ChannelsJson, channel.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var total = source.Count;
|
||||
var items = source
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberReachMessage?> FindMessageByIdAsync(long tenantId, long messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberReachMessages
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == messageId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberReachMessages.AddAsync(message, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberReachMessages.Update(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteMessageAsync(MemberReachMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberReachMessages.Remove(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberReachRecipient>> GetRecipientsAsync(
|
||||
long tenantId,
|
||||
long messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.MemberReachRecipients
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.MessageId == messageId)
|
||||
.OrderBy(item => item.MemberId)
|
||||
.ThenBy(item => item.Channel)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveRecipientsAsync(long tenantId, long messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var recipients = await context.MemberReachRecipients
|
||||
.Where(item => item.TenantId == tenantId && item.MessageId == messageId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.MemberReachRecipients.RemoveRange(recipients);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddRecipientsAsync(
|
||||
IReadOnlyCollection<MemberReachRecipient> recipients,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await context.MemberReachRecipients.AddRangeAsync(recipients, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<MemberMessageTemplate> Items, int Total)> SearchTemplatesAsync(
|
||||
long tenantId,
|
||||
MemberMessageTemplateCategory? category,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.MemberMessageTemplates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId);
|
||||
|
||||
if (category.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Category == category.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(item => EF.Functions.ILike(item.Name, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.UsageCount)
|
||||
.ThenByDescending(item => item.UpdatedAt ?? item.CreatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberMessageTemplate?> FindTemplateByIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberMessageTemplates
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == templateId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberMessageTemplate?> FindTemplateByNameAsync(long tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedName = (name ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
return Task.FromResult<MemberMessageTemplate?>(null);
|
||||
}
|
||||
|
||||
return context.MemberMessageTemplates
|
||||
.FirstOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == tenantId &&
|
||||
EF.Functions.ILike(item.Name, normalizedName),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberMessageTemplates.AddAsync(template, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberMessageTemplates.Update(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteTemplateAsync(MemberMessageTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberMessageTemplates.Remove(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberMessageMonthlyStatsSnapshot> GetMonthlyStatsAsync(
|
||||
long tenantId,
|
||||
DateTime monthStartUtc,
|
||||
DateTime monthEndUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sentMessageCount = await context.MemberReachMessages
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.Status == MemberMessageStatus.Sent &&
|
||||
item.SentAt.HasValue &&
|
||||
item.SentAt.Value >= monthStartUtc &&
|
||||
item.SentAt.Value < monthEndUtc)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var recipients = await context.MemberReachRecipients
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.SentAt.HasValue &&
|
||||
item.SentAt.Value >= monthStartUtc &&
|
||||
item.SentAt.Value < monthEndUtc)
|
||||
.Select(item => new
|
||||
{
|
||||
item.MemberId,
|
||||
item.Status,
|
||||
item.ReadAt,
|
||||
item.ConvertedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var reachMemberCount = recipients
|
||||
.Where(item => item.Status == MemberMessageRecipientStatus.Sent)
|
||||
.Select(item => item.MemberId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
var sentRecipientCount = recipients.Count(item => item.Status == MemberMessageRecipientStatus.Sent);
|
||||
var readRecipientCount = recipients.Count(item => item.ReadAt.HasValue);
|
||||
var convertedRecipientCount = recipients.Count(item => item.ConvertedAt.HasValue);
|
||||
|
||||
return new MemberMessageMonthlyStatsSnapshot(
|
||||
sentMessageCount,
|
||||
reachMemberCount,
|
||||
sentRecipientCount,
|
||||
readRecipientCount,
|
||||
convertedRecipientCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool HasChannel(string channelsJson, MemberMessageChannel channel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channelsJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var channels = JsonSerializer.Deserialize<List<string>>(channelsJson) ?? [];
|
||||
var target = channel switch
|
||||
{
|
||||
MemberMessageChannel.InApp => "inapp",
|
||||
MemberMessageChannel.Sms => "sms",
|
||||
MemberMessageChannel.WeChatMini => "wechat-mini",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return channels.Any(item => string.Equals(item, target, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,25 @@ public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRep
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsByMemberIdsAsync(
|
||||
long tenantId,
|
||||
IReadOnlyCollection<long> memberProfileIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (memberProfileIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await context.MemberProfileTags
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && memberProfileIds.Contains(x.MemberProfileId))
|
||||
.OrderBy(x => x.MemberProfileId)
|
||||
.ThenBy(x => x.TagName)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReplaceProfileTagsAsync(
|
||||
long tenantId,
|
||||
|
||||
@@ -27,6 +27,23 @@ public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUse
|
||||
public Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MiniUser>> GetByIdsAsync(
|
||||
IReadOnlyCollection<long> ids,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.MiniUsers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新小程序用户信息。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMemberMessageReachModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_message_templates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "模板名称。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "模板分类。"),
|
||||
Content = table.Column<string>(type: "text", nullable: false, comment: "模板内容。"),
|
||||
UsageCount = table.Column<int>(type: "integer", nullable: false, comment: "使用次数。"),
|
||||
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近使用时间(UTC)。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_message_templates", x => x.Id);
|
||||
},
|
||||
comment: "会员消息模板。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_reach_messages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识。"),
|
||||
TemplateId = table.Column<long>(type: "bigint", nullable: true, comment: "模板标识。"),
|
||||
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "消息标题。"),
|
||||
Content = table.Column<string>(type: "text", nullable: false, comment: "消息内容。"),
|
||||
ChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "发送渠道 JSON。"),
|
||||
AudienceType = table.Column<int>(type: "integer", nullable: false, comment: "目标人群类型。"),
|
||||
AudienceTagsJson = table.Column<string>(type: "text", nullable: false, comment: "目标标签 JSON。"),
|
||||
EstimatedReachCount = table.Column<int>(type: "integer", nullable: false, comment: "预计触达人数。"),
|
||||
ScheduleType = table.Column<int>(type: "integer", nullable: false, comment: "发送时间类型。"),
|
||||
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "定时发送时间(UTC)。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "消息状态。"),
|
||||
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发送时间(UTC)。"),
|
||||
SentCount = table.Column<int>(type: "integer", nullable: false, comment: "发送成功数量。"),
|
||||
ReadCount = table.Column<int>(type: "integer", nullable: false, comment: "已读数量。"),
|
||||
ConvertedCount = table.Column<int>(type: "integer", nullable: false, comment: "转化数量。"),
|
||||
HangfireJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "Hangfire 任务 ID。"),
|
||||
LastError = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "最后错误信息。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_reach_messages", x => x.Id);
|
||||
},
|
||||
comment: "会员消息触达主记录。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_reach_recipients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
MessageId = table.Column<long>(type: "bigint", nullable: false, comment: "消息标识。"),
|
||||
MemberId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
|
||||
Channel = table.Column<int>(type: "integer", nullable: false, comment: "触达渠道。"),
|
||||
Mobile = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "手机号快照。"),
|
||||
OpenId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "OpenId 快照。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "发送状态。"),
|
||||
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发送时间(UTC)。"),
|
||||
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "已读时间(UTC)。"),
|
||||
ConvertedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "转化时间(UTC)。"),
|
||||
ErrorMessage = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "失败摘要。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_reach_recipients", x => x.Id);
|
||||
},
|
||||
comment: "会员消息触达收件明细。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_message_templates_TenantId_Category_UsageCount",
|
||||
table: "member_message_templates",
|
||||
columns: new[] { "TenantId", "Category", "UsageCount" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_message_templates_TenantId_Name",
|
||||
table: "member_message_templates",
|
||||
columns: new[] { "TenantId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_messages_TenantId_CreatedAt",
|
||||
table: "member_reach_messages",
|
||||
columns: new[] { "TenantId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_messages_TenantId_Status_ScheduledAt",
|
||||
table: "member_reach_messages",
|
||||
columns: new[] { "TenantId", "Status", "ScheduledAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_recipients_TenantId_MessageId_MemberId_Channel",
|
||||
table: "member_reach_recipients",
|
||||
columns: new[] { "TenantId", "MessageId", "MemberId", "Channel" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_member_reach_recipients_TenantId_MessageId_Status",
|
||||
table: "member_reach_recipients",
|
||||
columns: new[] { "TenantId", "MessageId", "Status" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "member_message_templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "member_reach_messages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "member_reach_recipients");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user