feat: add content navigation and tenant configuration

This commit is contained in:
xiong
2026-07-26 05:05:24 +08:00
parent 5dfb68d8cc
commit e41b38e3a1
13 changed files with 6197 additions and 1 deletions

View File

@@ -0,0 +1,260 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class ContentEntryConfiguration : IEntityTypeConfiguration<ContentEntry>
{
public void Configure(EntityTypeBuilder<ContentEntry> builder)
{
builder.ConfigureTenantEntity("content_entries");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.EntryKey).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.EntryType).HasSnakeCaseEnum();
builder.Property(entity => entity.Icon).HasMaxLength(100);
builder.Property(entity => entity.Route).HasMaxLength(500);
builder.Property(entity => entity.Description).HasMaxLength(2000);
builder.Property(entity => entity.Visibility).HasSnakeCaseEnum();
builder.Property(entity => entity.AccessRules).IsJson("{}");
builder.Property(entity => entity.LayoutConfig).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.EntryKey }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new
{
entity.TenantId,
entity.RegionId,
entity.EntryType,
entity.IsActive,
entity.SortOrder
});
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UpdatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class ContentNodeConfiguration : IEntityTypeConfiguration<ContentNode>
{
public void Configure(EntityTypeBuilder<ContentNode> builder)
{
builder.ConfigureTenantEntity("content_nodes");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.NodeKey).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.NodeType).HasSnakeCaseEnum();
builder.Property(entity => entity.MarkerType).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.MarkerConfig).IsJson("{}");
builder.Property(entity => entity.Path).HasColumnType("ltree");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.EntryId, entity.NodeKey }).IsUnique();
builder.HasIndex(entity => new
{
entity.TenantId,
entity.EntryId,
entity.ParentId,
entity.SortOrder
});
builder.HasIndex(entity => entity.Path).HasMethod("gist");
builder.HasIndex(entity => new { entity.TenantId, entity.MarkerType })
.HasFilter("marker_type is not null");
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_content_nodes_depth", "depth >= 0");
});
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.EntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ParentId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UpdatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class QuestionCollectionConfiguration : IEntityTypeConfiguration<QuestionCollection>
{
public void Configure(EntityTypeBuilder<QuestionCollection> builder)
{
builder.ConfigureTenantEntity("question_collections");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.CollectionType).HasSnakeCaseEnum();
builder.Property(entity => entity.SourceType).HasSnakeCaseEnum();
builder.Property(entity => entity.Filters).IsJson("{}");
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new
{
entity.TenantId,
entity.NodeId,
entity.Status,
entity.SortOrder
});
builder.ToTable(table =>
{
table.HasCheckConstraint(
"ck_question_collections_question_count",
"question_count >= 0");
table.HasCheckConstraint(
"ck_question_collections_duration",
"duration_minutes is null or duration_minutes > 0");
});
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.EntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Subject>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Category>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.CategoryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionBankId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UpdatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class QuestionCollectionItemConfiguration :
IEntityTypeConfiguration<QuestionCollectionItem>
{
public void Configure(EntityTypeBuilder<QuestionCollectionItem> builder)
{
builder.ConfigureTenantEntity("question_collection_items");
builder.ConfigureTimestamps();
builder.Property(entity => entity.SectionKey).HasMaxLength(100);
builder.Property(entity => entity.Score).HasPrecision(8, 2);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new
{
entity.TenantId,
entity.CollectionId,
entity.QuestionId
}).IsUnique();
builder.HasIndex(entity => new
{
entity.TenantId,
entity.CollectionId,
entity.SectionKey,
entity.SortOrder
});
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PracticeBlueprintConfiguration : IEntityTypeConfiguration<PracticeBlueprint>
{
public void Configure(EntityTypeBuilder<PracticeBlueprint> builder)
{
builder.ConfigureTenantEntity("practice_blueprints");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
builder.Property(entity => entity.AssemblyType).HasSnakeCaseEnum();
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.PassScore).HasPrecision(8, 2);
builder.Property(entity => entity.Sections).IsJson("[]");
builder.Property(entity => entity.Rules).IsJson("{}");
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new
{
entity.TenantId,
entity.NodeId,
entity.CollectionId,
entity.Mode,
entity.Status,
entity.SortOrder
});
builder.ToTable(table =>
{
table.HasCheckConstraint(
"ck_practice_blueprints_question_limit",
"question_limit is null or question_limit > 0");
table.HasCheckConstraint(
"ck_practice_blueprints_duration",
"duration_minutes is null or duration_minutes > 0");
});
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.EntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UpdatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
@@ -16,12 +17,30 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
builder.Property(entity => entity.Mode).HasMaxLength(50);
builder.Property(entity => entity.TargetType).HasMaxLength(50);
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.QuestionIds).IsJson("[]");
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PracticeBlueprint>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.BlueprintId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.EntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ContentNodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
@@ -37,6 +38,7 @@ internal sealed class QuestionConfiguration : IEntityTypeConfiguration<Question>
builder.Property(entity => entity.Type).HasMaxLength(50);
builder.Property(entity => entity.TypeLabel).HasMaxLength(100);
builder.Property(entity => entity.Tags).IsJson("[]");
builder.Property(entity => entity.ExamMarkers).IsJson("{}");
builder.Property(entity => entity.MediaUrl).HasMaxLength(2048);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
@@ -57,6 +59,18 @@ internal sealed class QuestionConfiguration : IEntityTypeConfiguration<Question>
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.EntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ContentNodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PrimaryCollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.Id, entity.CurrentVersionId })

View File

@@ -51,3 +51,61 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class TenantDomainConfiguration : IEntityTypeConfiguration<TenantDomain>
{
public void Configure(EntityTypeBuilder<TenantDomain> builder)
{
builder.ConfigureTenantEntity("tenant_domains");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Host).HasColumnType("citext").HasMaxLength(253);
builder.Property(entity => entity.DomainType).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.VerificationToken).HasMaxLength(256);
builder.HasIndex(entity => entity.Host).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.IsPrimary })
.IsUnique()
.HasFilter("is_primary");
}
}
internal sealed class TenantBrandingConfiguration : IEntityTypeConfiguration<TenantBranding>
{
public void Configure(EntityTypeBuilder<TenantBranding> builder)
{
builder.ToTable("tenant_branding");
builder.HasKey(entity => entity.TenantId);
builder.ConfigureTimestamps();
builder.Property(entity => entity.BrandName).HasMaxLength(200);
builder.Property(entity => entity.ShortName).HasMaxLength(100);
builder.Property(entity => entity.Slogan).HasMaxLength(300);
builder.Property(entity => entity.OrganizationName).HasMaxLength(300);
builder.Property(entity => entity.LogoUrl).HasMaxLength(2048);
builder.Property(entity => entity.FaviconUrl).HasMaxLength(2048);
builder.Property(entity => entity.ServiceWechat).HasMaxLength(100);
builder.Property(entity => entity.ServiceAccountName).HasMaxLength(200);
builder.Property(entity => entity.Theme).IsJson("{}");
builder.Property(entity => entity.PublicAssets).IsJson("{}");
builder.HasOne<Tenant>().WithOne()
.HasForeignKey<TenantBranding>(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration<TenantSettings>
{
public void Configure(EntityTypeBuilder<TenantSettings> builder)
{
builder.ToTable("tenant_settings");
builder.HasKey(entity => entity.TenantId);
builder.ConfigureTimestamps();
builder.Property(entity => entity.FeatureFlags).IsJson("{}");
builder.Property(entity => entity.AdminFeatureFlags).IsJson("{}");
builder.Property(entity => entity.PublicConfig).IsJson("{}");
builder.HasOne<Tenant>().WithOne()
.HasForeignKey<TenantSettings>(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
}
}