feat(member): implement member center management module
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s

This commit is contained in:
2026-03-03 20:38:31 +08:00
parent c2821202c7
commit d96ca4971a
40 changed files with 4846 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Merchants.Services;
using TakeoutSaaS.Domain.Orders.Repositories;
@@ -49,6 +50,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
services.AddScoped<IMemberRepository, EfMemberRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -390,6 +390,14 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<MemberTier> MemberTiers => Set<MemberTier>();
/// <summary>
/// 会员标签。
/// </summary>
public DbSet<MemberProfileTag> MemberProfileTags => Set<MemberProfileTag>();
/// <summary>
/// 会员日设置。
/// </summary>
public DbSet<MemberDaySetting> MemberDaySettings => Set<MemberDaySetting>();
/// <summary>
/// 积分流水。
/// </summary>
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
@@ -557,6 +565,8 @@ public sealed class TakeoutAppDbContext(
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
ConfigureChatMessage(modelBuilder.Entity<ChatMessage>());
@@ -1785,8 +1795,12 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Mobile).HasMaxLength(32).IsRequired();
builder.Property(x => x.Nickname).HasMaxLength(64);
builder.Property(x => x.AvatarUrl).HasMaxLength(256);
builder.Property(x => x.StoredBalance).HasPrecision(18, 2);
builder.Property(x => x.StoredRechargeBalance).HasPrecision(18, 2);
builder.Property(x => x.StoredGiftBalance).HasPrecision(18, 2);
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.Mobile }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.MemberTierId });
}
private static void ConfigureMemberTier(EntityTypeBuilder<MemberTier> builder)
@@ -1794,8 +1808,33 @@ public sealed class TakeoutAppDbContext(
builder.ToTable("member_tiers");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.IconKey).HasMaxLength(32).IsRequired();
builder.Property(x => x.ColorHex).HasMaxLength(16).IsRequired();
builder.Property(x => x.UpgradeRuleType).HasMaxLength(16).IsRequired();
builder.Property(x => x.UpgradeAmountThreshold).HasPrecision(18, 2);
builder.Property(x => x.BenefitsJson).HasColumnType("text");
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.SortOrder });
}
private static void ConfigureMemberProfileTag(EntityTypeBuilder<MemberProfileTag> builder)
{
builder.ToTable("member_profile_tags");
builder.HasKey(x => x.Id);
builder.Property(x => x.MemberProfileId).IsRequired();
builder.Property(x => x.TagName).HasMaxLength(32).IsRequired();
builder.HasIndex(x => new { x.TenantId, x.MemberProfileId, x.TagName }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.MemberProfileId });
}
private static void ConfigureMemberDaySetting(EntityTypeBuilder<MemberDaySetting> builder)
{
builder.ToTable("member_day_settings");
builder.HasKey(x => x.Id);
builder.Property(x => x.IsEnabled).IsRequired();
builder.Property(x => x.Weekday).IsRequired();
builder.Property(x => x.ExtraDiscountRate).HasPrecision(5, 2);
builder.HasIndex(x => x.TenantId).IsUnique();
}
private static void ConfigureMemberPointLedger(EntityTypeBuilder<MemberPointLedger> builder)

View File

@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 会员聚合 EF Core 仓储实现。
/// </summary>
public sealed class EfMemberRepository(TakeoutAppDbContext context) : IMemberRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfile>> GetProfilesAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.MemberProfiles
.Where(x => x.TenantId == tenantId)
.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt)
.ThenByDescending(x => x.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfile>> GetProfilesByMobilesAsync(
long tenantId,
IReadOnlyCollection<string> mobiles,
CancellationToken cancellationToken = default)
{
if (mobiles.Count == 0)
{
return [];
}
return await context.MemberProfiles
.Where(x => x.TenantId == tenantId && mobiles.Contains(x.Mobile))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberProfile?> FindProfileByIdAsync(long tenantId, long memberId, CancellationToken cancellationToken = default)
{
return context.MemberProfiles
.Where(x => x.TenantId == tenantId && x.Id == memberId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddProfilesAsync(IEnumerable<MemberProfile> profiles, CancellationToken cancellationToken = default)
{
var profileList = profiles?.ToList() ?? [];
if (profileList.Count == 0)
{
return;
}
await context.MemberProfiles.AddRangeAsync(profileList, cancellationToken);
}
/// <inheritdoc />
public Task UpdateProfileAsync(MemberProfile profile, CancellationToken cancellationToken = default)
{
context.MemberProfiles.Update(profile);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberTier>> GetTiersAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.MemberTiers
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<MemberTier?> FindTierByIdAsync(long tenantId, long tierId, CancellationToken cancellationToken = default)
{
return context.MemberTiers
.Where(x => x.TenantId == tenantId && x.Id == tierId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
{
return context.MemberTiers.AddAsync(tier, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
{
context.MemberTiers.Update(tier);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteTierAsync(MemberTier tier, CancellationToken cancellationToken = default)
{
context.MemberTiers.Remove(tier);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<MemberDaySetting?> GetMemberDaySettingAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.MemberDaySettings
.Where(x => x.TenantId == tenantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default)
{
return context.MemberDaySettings.AddAsync(setting, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateMemberDaySettingAsync(MemberDaySetting setting, CancellationToken cancellationToken = default)
{
context.MemberDaySettings.Update(setting);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MemberProfileTag>> GetProfileTagsAsync(
long tenantId,
long memberProfileId,
CancellationToken cancellationToken = default)
{
return await context.MemberProfileTags
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MemberProfileId == memberProfileId)
.OrderBy(x => x.TagName)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task ReplaceProfileTagsAsync(
long tenantId,
long memberProfileId,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken = default)
{
var normalizedTags = (tags ?? Array.Empty<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var existing = await context.MemberProfileTags
.Where(x => x.TenantId == tenantId && x.MemberProfileId == memberProfileId)
.ToListAsync(cancellationToken);
context.MemberProfileTags.RemoveRange(existing);
if (normalizedTags.Count == 0)
{
return;
}
var entities = normalizedTags.Select(tag => new MemberProfileTag
{
MemberProfileId = memberProfileId,
TagName = tag
});
await context.MemberProfileTags.AddRangeAsync(entities, cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,225 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMemberCenterModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ColorHex",
table: "member_tiers",
type: "character varying(16)",
maxLength: 16,
nullable: false,
defaultValue: "#999999");
migrationBuilder.AddColumn<int>(
name: "DowngradeWindowDays",
table: "member_tiers",
type: "integer",
nullable: false,
defaultValue: 90);
migrationBuilder.AddColumn<string>(
name: "IconKey",
table: "member_tiers",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "user");
migrationBuilder.AddColumn<bool>(
name: "IsDefault",
table: "member_tiers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<decimal>(
name: "UpgradeAmountThreshold",
table: "member_tiers",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "UpgradeOrderCountThreshold",
table: "member_tiers",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "UpgradeRuleType",
table: "member_tiers",
type: "character varying(16)",
maxLength: 16,
nullable: false,
defaultValue: "none");
migrationBuilder.AddColumn<decimal>(
name: "StoredBalance",
table: "member_profiles",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "StoredGiftBalance",
table: "member_profiles",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "StoredRechargeBalance",
table: "member_profiles",
type: "numeric(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "member_day_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用会员日。"),
Weekday = table.Column<int>(type: "integer", nullable: false, comment: "周几1-7。"),
ExtraDiscountRate = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false, 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_day_settings", x => x.Id);
},
comment: "会员日设置。");
migrationBuilder.CreateTable(
name: "member_profile_tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MemberProfileId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
TagName = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, 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_profile_tags", x => x.Id);
},
comment: "会员标签。");
migrationBuilder.CreateIndex(
name: "IX_member_day_settings_TenantId",
table: "member_day_settings",
column: "TenantId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_member_profiles_TenantId_MemberTierId",
table: "member_profiles",
columns: new[] { "TenantId", "MemberTierId" });
migrationBuilder.CreateIndex(
name: "IX_member_profile_tags_TenantId_MemberProfileId",
table: "member_profile_tags",
columns: new[] { "TenantId", "MemberProfileId" });
migrationBuilder.CreateIndex(
name: "IX_member_profile_tags_TenantId_MemberProfileId_TagName",
table: "member_profile_tags",
columns: new[] { "TenantId", "MemberProfileId", "TagName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_member_tiers_TenantId_SortOrder",
table: "member_tiers",
columns: new[] { "TenantId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "member_day_settings");
migrationBuilder.DropTable(
name: "member_profile_tags");
migrationBuilder.DropIndex(
name: "IX_member_profiles_TenantId_MemberTierId",
table: "member_profiles");
migrationBuilder.DropIndex(
name: "IX_member_tiers_TenantId_SortOrder",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "ColorHex",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "DowngradeWindowDays",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "IconKey",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "IsDefault",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "UpgradeAmountThreshold",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "UpgradeOrderCountThreshold",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "UpgradeRuleType",
table: "member_tiers");
migrationBuilder.DropColumn(
name: "StoredBalance",
table: "member_profiles");
migrationBuilder.DropColumn(
name: "StoredGiftBalance",
table: "member_profiles");
migrationBuilder.DropColumn(
name: "StoredRechargeBalance",
table: "member_profiles");
}
}
}