refactor: 收敛租户领域至最小集
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantBillingStatement"/> EF Core 映射配置。
|
||||
/// </summary>
|
||||
public sealed class TenantBillingStatementConfiguration : IEntityTypeConfiguration<TenantBillingStatement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<TenantBillingStatement> builder)
|
||||
{
|
||||
builder.ToTable("tenant_billing_statements");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// 1. 字段约束
|
||||
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.BillingType).HasConversion<int>();
|
||||
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
|
||||
builder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
|
||||
builder.Property(x => x.Currency).HasMaxLength(8).HasDefaultValue("CNY");
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
|
||||
// 2. JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移)
|
||||
builder.Property(x => x.LineItemsJson).HasColumnType("text");
|
||||
|
||||
// 3. 备注字段
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
// 4. 唯一约束与索引
|
||||
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
|
||||
|
||||
// 5. 性能索引(高频查询:租户+状态+到期日)
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate })
|
||||
.HasDatabaseName("idx_billing_tenant_status_duedate");
|
||||
|
||||
// 6. 逾期扫描索引(仅索引 Pending/Overdue)
|
||||
builder.HasIndex(x => new { x.Status, x.DueDate })
|
||||
.HasDatabaseName("idx_billing_status_duedate")
|
||||
.HasFilter($"\"Status\" IN ({(int)TenantBillingStatus.Pending}, {(int)TenantBillingStatus.Overdue})");
|
||||
|
||||
// 7. 创建时间索引(支持列表倒序)
|
||||
builder.HasIndex(x => x.CreatedAt)
|
||||
.HasDatabaseName("idx_billing_created_at");
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantPayment"/> EF Core 映射配置。
|
||||
/// </summary>
|
||||
public sealed class TenantPaymentConfiguration : IEntityTypeConfiguration<TenantPayment>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<TenantPayment> builder)
|
||||
{
|
||||
builder.ToTable("tenant_payments");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// 1. 字段约束
|
||||
builder.Property(x => x.BillingStatementId).IsRequired();
|
||||
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Method).HasConversion<int>();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.TransactionNo).HasMaxLength(64);
|
||||
builder.Property(x => x.ProofUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.RefundReason).HasMaxLength(512);
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
// 2. 复合索引:租户+账单
|
||||
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
|
||||
|
||||
// 3. 支付记录时间排序索引
|
||||
builder.HasIndex(x => new { x.BillingStatementId, x.PaidAt })
|
||||
.HasDatabaseName("idx_payment_billing_paidat");
|
||||
|
||||
// 4. 交易号索引(部分索引:仅非空)
|
||||
builder.HasIndex(x => x.TransactionNo)
|
||||
.HasDatabaseName("idx_payment_transaction_no")
|
||||
.HasFilter("\"TransactionNo\" IS NOT NULL");
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,9 @@ using TakeoutSaaS.Domain.Reservations.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
@@ -42,62 +40,6 @@ public class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
/// <summary>
|
||||
/// 租户套餐。
|
||||
/// </summary>
|
||||
public DbSet<TenantPackage> TenantPackages => Set<TenantPackage>();
|
||||
/// <summary>
|
||||
/// 租户订阅。
|
||||
/// </summary>
|
||||
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
|
||||
/// <summary>
|
||||
/// 租户订阅历史。
|
||||
/// </summary>
|
||||
public DbSet<TenantSubscriptionHistory> TenantSubscriptionHistories => Set<TenantSubscriptionHistory>();
|
||||
/// <summary>
|
||||
/// 租户配额使用记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantQuotaUsage> TenantQuotaUsages => Set<TenantQuotaUsage>();
|
||||
/// <summary>
|
||||
/// 租户配额使用历史记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantQuotaUsageHistory> TenantQuotaUsageHistories => Set<TenantQuotaUsageHistory>();
|
||||
/// <summary>
|
||||
/// 租户账单。
|
||||
/// </summary>
|
||||
public DbSet<TenantBillingStatement> TenantBillingStatements => Set<TenantBillingStatement>();
|
||||
/// <summary>
|
||||
/// 租户支付记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantPayment> TenantPayments => Set<TenantPayment>();
|
||||
/// <summary>
|
||||
/// 租户通知。
|
||||
/// </summary>
|
||||
public DbSet<TenantNotification> TenantNotifications => Set<TenantNotification>();
|
||||
/// <summary>
|
||||
/// 租户公告。
|
||||
/// </summary>
|
||||
public DbSet<TenantAnnouncement> TenantAnnouncements => Set<TenantAnnouncement>();
|
||||
/// <summary>
|
||||
/// 租户公告已读记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantAnnouncementRead> TenantAnnouncementReads => Set<TenantAnnouncementRead>();
|
||||
/// <summary>
|
||||
/// 租户认证资料。
|
||||
/// </summary>
|
||||
public DbSet<TenantVerificationProfile> TenantVerificationProfiles => Set<TenantVerificationProfile>();
|
||||
/// <summary>
|
||||
/// 租户审核领取记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantReviewClaim> TenantReviewClaims => Set<TenantReviewClaim>();
|
||||
/// <summary>
|
||||
/// 配额包定义。
|
||||
/// </summary>
|
||||
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
|
||||
/// <summary>
|
||||
/// 租户配额包购买记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantQuotaPackagePurchase> TenantQuotaPackagePurchases => Set<TenantQuotaPackagePurchase>();
|
||||
/// <summary>
|
||||
/// 商户实体。
|
||||
/// </summary>
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
@@ -401,20 +343,6 @@ public class TakeoutAppDbContext(
|
||||
ConfigureTenant(modelBuilder.Entity<Tenant>());
|
||||
ConfigureMerchant(modelBuilder.Entity<Merchant>());
|
||||
ConfigureStore(modelBuilder.Entity<Store>());
|
||||
ConfigureTenantPackage(modelBuilder.Entity<TenantPackage>());
|
||||
ConfigureTenantSubscription(modelBuilder.Entity<TenantSubscription>());
|
||||
ConfigureTenantSubscriptionHistory(modelBuilder.Entity<TenantSubscriptionHistory>());
|
||||
ConfigureTenantQuotaUsage(modelBuilder.Entity<TenantQuotaUsage>());
|
||||
ConfigureTenantQuotaUsageHistory(modelBuilder.Entity<TenantQuotaUsageHistory>());
|
||||
ConfigureTenantBilling(modelBuilder.Entity<TenantBillingStatement>());
|
||||
ConfigureTenantPayment(modelBuilder.Entity<TenantPayment>());
|
||||
ConfigureTenantNotification(modelBuilder.Entity<TenantNotification>());
|
||||
ConfigureTenantAnnouncement(modelBuilder.Entity<TenantAnnouncement>());
|
||||
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
|
||||
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
|
||||
ConfigureTenantReviewClaim(modelBuilder.Entity<TenantReviewClaim>());
|
||||
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
||||
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
|
||||
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
|
||||
ConfigureMerchantContract(modelBuilder.Entity<MerchantContract>());
|
||||
ConfigureMerchantStaff(modelBuilder.Entity<MerchantStaff>());
|
||||
@@ -502,51 +430,6 @@ public class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => x.ContactPhone).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureTenantVerificationProfile(EntityTypeBuilder<TenantVerificationProfile> builder)
|
||||
{
|
||||
builder.ToTable("tenant_verification_profiles");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64);
|
||||
builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.LegalPersonName).HasMaxLength(64);
|
||||
builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32);
|
||||
builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.BankAccountName).HasMaxLength(128);
|
||||
builder.Property(x => x.BankAccountNumber).HasMaxLength(64);
|
||||
builder.Property(x => x.BankName).HasMaxLength(128);
|
||||
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
|
||||
builder.Property(x => x.ReviewedByName).HasMaxLength(64);
|
||||
builder.HasIndex(x => x.TenantId).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureTenantReviewClaim(EntityTypeBuilder<TenantReviewClaim> builder)
|
||||
{
|
||||
builder.ToTable("tenant_review_claims");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.ClaimedBy).IsRequired();
|
||||
builder.Property(x => x.ClaimedByName).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.ClaimedAt).IsRequired();
|
||||
builder.Property(x => x.ReleasedAt);
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => x.ClaimedBy);
|
||||
builder.HasIndex(x => x.TenantId).IsUnique().HasFilter("\"ReleasedAt\" IS NULL AND \"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
|
||||
private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder<TenantSubscriptionHistory> builder)
|
||||
{
|
||||
builder.ToTable("tenant_subscription_histories");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.TenantSubscriptionId).IsRequired();
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
builder.Property(x => x.Currency).HasMaxLength(8);
|
||||
builder.HasIndex(x => new { x.TenantId, x.TenantSubscriptionId });
|
||||
}
|
||||
|
||||
private static void ConfigureMerchant(EntityTypeBuilder<Merchant> builder)
|
||||
{
|
||||
builder.ToTable("merchants");
|
||||
@@ -774,141 +657,6 @@ public class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.OrderId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureTenantPackage(EntityTypeBuilder<TenantPackage> builder)
|
||||
{
|
||||
builder.ToTable("tenant_packages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text");
|
||||
builder.Property(x => x.PublishStatus)
|
||||
.HasConversion<int>()
|
||||
.HasDefaultValue(TenantPackagePublishStatus.Draft)
|
||||
.HasSentinel((TenantPackagePublishStatus)(-1))
|
||||
.HasComment("发布状态:0=草稿,1=已发布。");
|
||||
|
||||
// 1. 解决 EF Core 默认值哨兵问题:当我们希望插入 false/0 时,若数据库配置了 default 且 EF 认为该值是“未设置”,会导致 insert 省略列,最终落库为默认值。
|
||||
// 2. 发布状态使用 -1 作为哨兵,避免 Draft=0 被误判为“未设置”而触发数据库默认值(发布/草稿切换必须可控)。
|
||||
// 3. 将布尔开关哨兵值设置为数据库默认值:true 作为哨兵,false 才会被显式写入,从而保证“可见性/可售开关”在新增时可正确落库。
|
||||
builder.Property(x => x.IsPublicVisible)
|
||||
.HasDefaultValue(true)
|
||||
.HasSentinel(true)
|
||||
.HasComment("是否对外可见(展示页/套餐列表可见性)。");
|
||||
builder.Property(x => x.IsAllowNewTenantPurchase)
|
||||
.HasDefaultValue(true)
|
||||
.HasSentinel(true)
|
||||
.HasComment("是否允许新租户购买/选择(仅影响新购)。");
|
||||
|
||||
// 4. 展示配置:推荐标识与标签(用于套餐展示页/对比页)
|
||||
builder.Property(x => x.IsRecommended)
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否推荐展示(运营推荐标识)。");
|
||||
builder.Property(x => x.Tags)
|
||||
.HasColumnType("text[]")
|
||||
.HasComment("套餐标签(用于展示与对比页)。");
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(0).HasComment("展示排序,数值越小越靠前。");
|
||||
builder.HasIndex(x => new { x.IsActive, x.SortOrder });
|
||||
builder.HasIndex(x => new { x.PublishStatus, x.IsActive, x.IsPublicVisible, x.IsAllowNewTenantPurchase, x.SortOrder });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantSubscription(EntityTypeBuilder<TenantSubscription> builder)
|
||||
{
|
||||
builder.ToTable("tenant_subscriptions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantPackageId).IsRequired();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.TenantPackageId });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantQuotaUsage(EntityTypeBuilder<TenantQuotaUsage> builder)
|
||||
{
|
||||
builder.ToTable("tenant_quota_usages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.QuotaType).HasConversion<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.QuotaType }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureTenantQuotaUsageHistory(EntityTypeBuilder<TenantQuotaUsageHistory> builder)
|
||||
{
|
||||
builder.ToTable("tenant_quota_usage_histories");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.QuotaType).HasConversion<int>();
|
||||
builder.Property(x => x.ChangeType).HasConversion<int>();
|
||||
builder.Property(x => x.ChangeReason).HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.QuotaType, x.RecordedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.RecordedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantBilling(EntityTypeBuilder<TenantBillingStatement> builder)
|
||||
{
|
||||
new TenantBillingStatementConfiguration().Configure(builder);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder)
|
||||
{
|
||||
new TenantPaymentConfiguration().Configure(builder);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantNotification(EntityTypeBuilder<TenantNotification> builder)
|
||||
{
|
||||
builder.ToTable("tenant_notifications");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Message).HasMaxLength(1024).IsRequired();
|
||||
builder.Property(x => x.Channel).HasConversion<int>();
|
||||
builder.Property(x => x.Severity).HasConversion<int>();
|
||||
builder.Property(x => x.MetadataJson).HasColumnType("text");
|
||||
builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantAnnouncement(EntityTypeBuilder<TenantAnnouncement> builder)
|
||||
{
|
||||
builder.ToTable("tenant_announcements");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.AnnouncementType)
|
||||
.HasConversion<int>();
|
||||
builder.Property(x => x.PublisherScope)
|
||||
.HasConversion<int>();
|
||||
builder.Property(x => x.PublisherUserId);
|
||||
builder.Property(x => x.Status)
|
||||
.HasConversion<int>();
|
||||
builder.Property(x => x.PublishedAt);
|
||||
builder.Property(x => x.RevokedAt);
|
||||
builder.Property(x => x.ScheduledPublishAt);
|
||||
builder.Property(x => x.TargetType).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.TargetParameters).HasColumnType("text");
|
||||
builder.Property(x => x.Priority).IsRequired();
|
||||
builder.Property<bool>("IsActive").IsRequired();
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsRowVersion()
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bytea");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex("TenantId", "AnnouncementType", "IsActive");
|
||||
builder.HasIndex(x => new { x.TenantId, x.EffectiveFrom, x.EffectiveTo });
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status, x.EffectiveFrom });
|
||||
builder.HasIndex(x => new { x.Status, x.EffectiveFrom })
|
||||
.HasFilter("\"TenantId\" = 0");
|
||||
}
|
||||
|
||||
private static void ConfigureTenantAnnouncementRead(EntityTypeBuilder<TenantAnnouncementRead> builder)
|
||||
{
|
||||
builder.ToTable("tenant_announcement_reads");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.AnnouncementId).IsRequired();
|
||||
builder.Property(x => x.UserId);
|
||||
builder.Property(x => x.ReadAt).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => new { x.TenantId, x.AnnouncementId, x.UserId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMerchantDocument(EntityTypeBuilder<MerchantDocument> builder)
|
||||
{
|
||||
builder.ToTable("merchant_documents");
|
||||
@@ -1538,30 +1286,4 @@ public class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.MetricDefinitionId, x.Severity });
|
||||
}
|
||||
|
||||
private static void ConfigureQuotaPackage(EntityTypeBuilder<QuotaPackage> builder)
|
||||
{
|
||||
builder.ToTable("quota_packages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.QuotaType).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.QuotaValue).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Price).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.IsActive).IsRequired();
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(0);
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.QuotaType, x.IsActive, x.SortOrder });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantQuotaPackagePurchase(EntityTypeBuilder<TenantQuotaPackagePurchase> builder)
|
||||
{
|
||||
builder.ToTable("tenant_quota_package_purchases");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.QuotaPackageId).IsRequired();
|
||||
builder.Property(x => x.QuotaValue).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Price).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.PurchasedAt).IsRequired();
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户聚合的 EF Core 仓储实现。
|
||||
/// 租户只读仓储实现(AdminApi 使用)。
|
||||
/// </summary>
|
||||
public sealed class EfTenantRepository(TakeoutAdminDbContext context, TakeoutLogsDbContext logsContext) : ITenantRepository
|
||||
public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 只读查询租户(跨租户)
|
||||
return context.Tenants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
|
||||
@@ -24,381 +22,16 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context, TakeoutLog
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<Tenant>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 批量查询租户
|
||||
return await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => tenantIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Tenant>> SearchAsync(
|
||||
TenantStatus? status,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = context.Tenants.AsNoTracking();
|
||||
|
||||
// 2. 按状态过滤
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
// 3. 按关键字过滤
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Name, $"%{keyword}%") ||
|
||||
EF.Functions.ILike(x.Code, $"%{keyword}%") ||
|
||||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%") ||
|
||||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{keyword}%"));
|
||||
}
|
||||
|
||||
// 4. 排序返回
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<Tenant> Items, int Total)> SearchPagedAsync(
|
||||
TenantStatus? status,
|
||||
TenantVerificationStatus? verificationStatus,
|
||||
string? name,
|
||||
string? contactName,
|
||||
string? contactPhone,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Tenants.AsNoTracking();
|
||||
|
||||
// 1. 按租户状态过滤
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
// 2. 按实名认证状态过滤(未提交视为 Draft)
|
||||
if (verificationStatus.HasValue)
|
||||
{
|
||||
query = from tenant in query
|
||||
join profile in context.TenantVerificationProfiles.AsNoTracking()
|
||||
on tenant.Id equals profile.TenantId into profiles
|
||||
from profile in profiles.DefaultIfEmpty()
|
||||
where (profile == null ? TenantVerificationStatus.Draft : profile.Status) == verificationStatus.Value
|
||||
select tenant;
|
||||
}
|
||||
|
||||
// 3. 按名称/联系人/电话过滤(模糊匹配)
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var normalizedName = name.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalizedName}%"));
|
||||
}
|
||||
|
||||
// 4. 按联系人过滤(模糊匹配)
|
||||
if (!string.IsNullOrWhiteSpace(contactName))
|
||||
{
|
||||
var normalizedContactName = contactName.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedContactName}%"));
|
||||
}
|
||||
|
||||
// 5. 按联系电话过滤(模糊匹配)
|
||||
if (!string.IsNullOrWhiteSpace(contactPhone))
|
||||
{
|
||||
var normalizedContactPhone = contactPhone.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedContactPhone}%"));
|
||||
}
|
||||
|
||||
// 6. 兼容关键字查询:名称/编码/联系人/电话
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%") ||
|
||||
EF.Functions.ILike(x.Code, $"%{normalizedKeyword}%") ||
|
||||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedKeyword}%") ||
|
||||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
// 7. 先统计总数,再按创建时间倒序分页
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 8. 查询当前页数据
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Tenants.AddAsync(tenant, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Tenants.Update(tenant);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = code.Trim();
|
||||
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化名称
|
||||
var normalized = name.Trim();
|
||||
|
||||
// 2. 构建查询(名称使用 ILike 做不区分大小写的等值匹配)
|
||||
var query = context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => EF.Functions.ILike(x.Name, normalized));
|
||||
|
||||
// 3. 更新场景排除自身
|
||||
if (excludeTenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeTenantId.Value);
|
||||
}
|
||||
|
||||
// 4. 判断是否存在
|
||||
return query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = phone.Trim();
|
||||
return context.Tenants.AnyAsync(x => x.ContactPhone == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long?> FindTenantIdByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化手机号
|
||||
var normalized = phone.Trim();
|
||||
// 2. 查询租户 ID
|
||||
return context.Tenants.AsNoTracking()
|
||||
.Where(x => x.ContactPhone == normalized)
|
||||
.Select(x => (long?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantVerificationProfile>> GetVerificationProfilesAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantVerificationProfile>();
|
||||
}
|
||||
|
||||
// 2. 批量查询实名资料
|
||||
return await context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询现有实名资料
|
||||
var existing = await context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
// 2. 不存在则新增
|
||||
await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 存在则更新当前值
|
||||
profile.Id = existing.Id;
|
||||
context.Entry(existing).CurrentValues.SetValues(profile);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantReviewClaim?> GetActiveReviewClaimAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantReviewClaims
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ReleasedAt == null)
|
||||
.OrderByDescending(x => x.ClaimedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantReviewClaim?> FindActiveReviewClaimAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantReviewClaims
|
||||
.Where(x => x.TenantId == tenantId && x.ReleasedAt == null)
|
||||
.OrderByDescending(x => x.ClaimedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryAddReviewClaimAsync(
|
||||
TenantReviewClaim claim,
|
||||
TenantAuditLog auditLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 写入领取记录
|
||||
await context.TenantReviewClaims.AddAsync(claim, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 2. 写入审计日志
|
||||
await logsContext.TenantAuditLogs.AddAsync(auditLog, cancellationToken);
|
||||
await logsContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
// 1. 释放实体跟踪避免重复写入
|
||||
context.Entry(claim).State = EntityState.Detached;
|
||||
|
||||
// 2. 返回抢占失败
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateReviewClaimAsync(TenantReviewClaim claim, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantReviewClaims.Update(claim);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> GetSubscriptionsAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantSubscription>();
|
||||
}
|
||||
|
||||
// 2. 批量查询订阅数据
|
||||
return await context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantSubscriptions.Update(subscription);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantSubscriptionHistories
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveFrom)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return logsContext.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.TenantAuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 保存业务库变更
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 2. 保存日志库变更
|
||||
await logsContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@ public sealed class TakeoutLogsDbContext(
|
||||
IIdGenerator? idGenerator = null)
|
||||
: AppDbContext(options, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户审计日志集合。
|
||||
/// </summary>
|
||||
public DbSet<TenantAuditLog> TenantAuditLogs => Set<TenantAuditLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 商户审计日志集合。
|
||||
/// </summary>
|
||||
@@ -55,7 +50,6 @@ public sealed class TakeoutLogsDbContext(
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureTenantAuditLog(modelBuilder.Entity<TenantAuditLog>());
|
||||
ConfigureMerchantAuditLog(modelBuilder.Entity<MerchantAuditLog>());
|
||||
ConfigureMerchantChangeLog(modelBuilder.Entity<MerchantChangeLog>());
|
||||
ConfigureOperationLog(modelBuilder.Entity<OperationLog>());
|
||||
@@ -63,17 +57,6 @@ public sealed class TakeoutLogsDbContext(
|
||||
ConfigureMemberGrowthLog(modelBuilder.Entity<MemberGrowthLog>());
|
||||
}
|
||||
|
||||
private static void ConfigureTenantAuditLog(EntityTypeBuilder<TenantAuditLog> builder)
|
||||
{
|
||||
builder.ToTable("tenant_audit_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(1024);
|
||||
builder.Property(x => x.OperatorName).HasMaxLength(64);
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
}
|
||||
|
||||
private static void ConfigureMerchantAuditLog(EntityTypeBuilder<MerchantAuditLog> builder)
|
||||
{
|
||||
builder.ToTable("merchant_audit_logs");
|
||||
|
||||
Reference in New Issue
Block a user