feat: add core persistence model

This commit is contained in:
xiong
2026-07-26 04:52:56 +08:00
parent f5109de879
commit 5dfb68d8cc
33 changed files with 6989 additions and 31 deletions

View File

@@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class RegionConfiguration : IEntityTypeConfiguration<Region>
{
public void Configure(EntityTypeBuilder<Region> builder)
{
builder.ConfigureTenantEntity("regions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Code).HasMaxLength(50);
builder.Property(entity => entity.ShortName).HasMaxLength(100);
builder.Property(entity => entity.FullName).HasMaxLength(300);
builder.Property(entity => entity.Icon).HasMaxLength(2048);
builder.Property(entity => entity.Pinyin).HasMaxLength(255);
builder.Property(entity => entity.Config).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
}
}
internal sealed class RegionModuleConfiguration : IEntityTypeConfiguration<RegionModule>
{
public void Configure(EntityTypeBuilder<RegionModule> builder)
{
builder.ConfigureTenantEntity("region_modules");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Type).HasMaxLength(50);
builder.Property(entity => entity.Icon).HasMaxLength(2048);
builder.Property(entity => entity.Color).HasMaxLength(50);
builder.Property(entity => entity.TextColor).HasMaxLength(50);
builder.Property(entity => entity.Route).HasMaxLength(500);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class ModuleNodeConfiguration : IEntityTypeConfiguration<ModuleNode>
{
public void Configure(EntityTypeBuilder<ModuleNode> builder)
{
builder.ConfigureTenantEntity("module_nodes");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacyParentId).HasMaxLength(64);
builder.Property(entity => entity.LegacyModuleId).HasMaxLength(64);
builder.Property(entity => entity.Type).HasSnakeCaseEnum();
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Path).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<RegionModule>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ParentId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class SchoolConfiguration : IEntityTypeConfiguration<School>
{
public void Configure(EntityTypeBuilder<School> builder)
{
builder.ConfigureTenantEntity("schools");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.ProfessionalExamDate).HasMaxLength(255);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<RegionModule>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class MajorConfiguration : IEntityTypeConfiguration<Major>
{
public void Configure(EntityTypeBuilder<Major> builder)
{
builder.ConfigureTenantEntity("majors");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<School>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class SubjectConfiguration : IEntityTypeConfiguration<Subject>
{
public void Configure(EntityTypeBuilder<Subject> builder)
{
builder.ConfigureTenantEntity("subjects");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Type).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.MajorLegacyIds).IsJson("[]");
builder.Property(entity => entity.Icon).HasMaxLength(2048);
builder.Property(entity => entity.Stats).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<RegionModule>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<School>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Major>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.MajorId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ConfigureTenantEntity("categories");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.CategoryType).HasNullableSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Subject>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,75 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal static class ConfigurationSupport
{
public static void ConfigureEntity<TEntity>(
this EntityTypeBuilder<TEntity> builder,
string tableName)
where TEntity : Entity
{
builder.ToTable(tableName);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
}
public static void ConfigureTenantEntity<TEntity>(
this EntityTypeBuilder<TEntity> builder,
string tableName)
where TEntity : TenantEntity
{
builder.ConfigureEntity(tableName);
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasOne<Tenant>()
.WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
}
public static void ConfigureTimestamps<TEntity>(this EntityTypeBuilder<TEntity> builder)
where TEntity : class, IHasTimestamps
{
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.UpdatedAt).HasDefaultValueSql("now()");
}
public static PropertyBuilder<JsonElement> IsJson(
this PropertyBuilder<JsonElement> property,
string defaultJson)
{
return property
.HasColumnType("jsonb")
.HasDefaultValueSql($"'{defaultJson}'::jsonb");
}
public static PropertyBuilder<TEnum> HasSnakeCaseEnum<TEnum>(
this PropertyBuilder<TEnum> property,
int maxLength = 32)
where TEnum : struct, Enum
{
return property
.HasConversion(new SnakeCaseEnumConverter<TEnum>())
.HasMaxLength(maxLength);
}
public static PropertyBuilder<TEnum?> HasNullableSnakeCaseEnum<TEnum>(
this PropertyBuilder<TEnum?> property,
int maxLength = 32)
where TEnum : struct, Enum
{
return property
.HasConversion(
value => value.HasValue
? ModelBuilderExtensions.ToSnakeCase(value.Value.ToString())
: null,
value => string.IsNullOrWhiteSpace(value)
? null
: Enum.Parse<TEnum>(value.Replace("_", string.Empty), true))
.HasMaxLength(maxLength);
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Identity;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ConfigureEntity("users");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Username).HasMaxLength(100);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
builder.Property(entity => entity.PrimaryRole).HasMaxLength(50);
builder.Property(entity => entity.LegacyPasswordHash).HasMaxLength(512);
builder.Property(entity => entity.RawProfile).IsJson("{}");
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasIndex(entity => entity.Username).IsUnique();
builder.HasIndex(entity => entity.Email).IsUnique();
builder.HasIndex(entity => entity.Phone).IsUnique();
}
}
internal sealed class UserIdentityConfiguration : IEntityTypeConfiguration<UserIdentity>
{
public void Configure(EntityTypeBuilder<UserIdentity> builder)
{
builder.ConfigureEntity("user_identities");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.ProviderSubject).HasMaxLength(255);
builder.Property(entity => entity.UnionId).HasMaxLength(255);
builder.Property(entity => entity.OpenId).HasMaxLength(255);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.SecretPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration<StudentProfile>
{
public void Configure(EntityTypeBuilder<StudentProfile> builder)
{
builder.ConfigureTenantEntity("student_profiles");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
builder.Property(entity => entity.Stats).IsJson("{}");
builder.Property(entity => entity.Progress).IsJson("{}");
builder.Property(entity => entity.ModuleSelections).IsJson("{}");
builder.Property(entity => entity.RecentActivities).IsJson("[]");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId }).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.UserId)
.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<School>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SelectedSchoolId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Major>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SelectedMajorId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,159 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<PracticeSession>
{
public void Configure(EntityTypeBuilder<PracticeSession> builder)
{
builder.ConfigureTenantEntity("practice_sessions");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.UserId, entity.Id });
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.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<AnswerRecord>
{
public void Configure(EntityTypeBuilder<AnswerRecord> builder)
{
builder.ConfigureTenantEntity("answer_records");
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacyQuestionId).HasMaxLength(64);
builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
builder.Property(entity => entity.SelectedOptions).IsJson("[]");
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
});
builder.HasIndex(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId
});
builder.ToTable(table => table.HasCheckConstraint(
"ck_answer_records_version_requires_question",
"question_version_id is null or question_id is not null"));
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.UserId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<FavoriteQuestion>
{
public void Configure(EntityTypeBuilder<FavoriteQuestion> builder)
{
builder.ToTable("favorite_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.Property(entity => entity.Source).HasMaxLength(50);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.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 WrongQuestionConfiguration : IEntityTypeConfiguration<WrongQuestion>
{
public void Configure(EntityTypeBuilder<WrongQuestion> builder)
{
builder.ToTable("wrong_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.Property(entity => entity.WrongCount).HasDefaultValue(1);
builder.Property(entity => entity.LastWrongAt).HasDefaultValueSql("now()");
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.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 RecentPracticeConfiguration : IEntityTypeConfiguration<RecentPractice>
{
public void Configure(EntityTypeBuilder<RecentPractice> builder)
{
builder.ConfigureTenantEntity("recent_practices");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.PracticeType).HasMaxLength(50);
builder.Property(entity => entity.TargetLegacyId).HasMaxLength(64);
builder.Property(entity => entity.TargetName).HasMaxLength(300);
builder.Property(entity => entity.Color).HasMaxLength(50);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastAccessAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<QuestionBank>
{
public void Configure(EntityTypeBuilder<QuestionBank> builder)
{
builder.ConfigureTenantEntity("question_banks");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.SourceScope).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class QuestionConfiguration : IEntityTypeConfiguration<Question>
{
public void Configure(EntityTypeBuilder<Question> builder)
{
builder.ConfigureTenantEntity("questions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacySubjectId).HasMaxLength(64);
builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
builder.Property(entity => entity.LegacyNodeId).HasMaxLength(64);
builder.Property(entity => entity.Type).HasMaxLength(50);
builder.Property(entity => entity.TypeLabel).HasMaxLength(100);
builder.Property(entity => entity.Tags).IsJson("[]");
builder.Property(entity => entity.MediaUrl).HasMaxLength(2048);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionBankId })
.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<ModuleNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.Id, entity.CurrentVersionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<QuestionVersion>
{
public void Configure(EntityTypeBuilder<QuestionVersion> builder)
{
builder.ConfigureEntity("question_versions");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasAlternateKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id });
builder.Property(entity => entity.Options).IsJson("[]");
builder.Property(entity => entity.CorrectOptionIndices).IsJson("[]");
builder.Property(entity => entity.SubQuestions).IsJson("[]");
builder.Property(entity => entity.CodeLang).HasMaxLength(50);
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.QuestionId, entity.VersionNo }).IsUnique();
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
{
public void Configure(EntityTypeBuilder<Tenant> builder)
{
builder.ConfigureEntity("tenants");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Slug).HasColumnType("citext").HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.LegalName).HasMaxLength(300);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
builder.Property(entity => entity.BillingStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.Slug).IsUnique();
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.OwnerUserId)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<TenantMembership>
{
public void Configure(EntityTypeBuilder<TenantMembership> builder)
{
builder.ConfigureTenantEntity("tenant_memberships");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Role).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Permissions).IsJson("{}");
builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Tiku.Infrastructure.Persistence;
internal static class ModelBuilderExtensions
{
public static void UseSnakeCaseIdentifiers(this ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
property.SetColumnName(ToSnakeCase(property.Name));
}
foreach (var key in entityType.GetKeys())
{
if (key.GetName() is { } keyName)
{
key.SetName(ToSnakeCase(keyName));
}
}
foreach (var foreignKey in entityType.GetForeignKeys())
{
if (foreignKey.GetConstraintName() is { } constraintName)
{
foreignKey.SetConstraintName(ToSnakeCase(constraintName));
}
}
foreach (var index in entityType.GetIndexes())
{
if (index.GetDatabaseName() is { } indexName)
{
index.SetDatabaseName(ToSnakeCase(indexName));
}
}
}
}
public static string ToSnakeCase(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
var builder = new StringBuilder(value.Length + 8);
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
if (char.IsUpper(character) && index > 0 &&
(char.IsLower(value[index - 1]) ||
index + 1 < value.Length && char.IsLower(value[index + 1])))
{
builder.Append('_');
}
builder.Append(char.ToLowerInvariant(character));
}
return builder.ToString();
}
}
internal sealed class SnakeCaseEnumConverter<TEnum>() : ValueConverter<TEnum, string>(
value => ModelBuilderExtensions.ToSnakeCase(value.ToString()),
value => Enum.Parse<TEnum>(value.Replace("_", string.Empty), true))
where TEnum : struct, Enum;

View File

@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence;
public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbContext(options)
{
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
public DbSet<StudentProfile> StudentProfiles => Set<StudentProfile>();
public DbSet<Region> Regions => Set<Region>();
public DbSet<RegionModule> RegionModules => Set<RegionModule>();
public DbSet<ModuleNode> ModuleNodes => Set<ModuleNode>();
public DbSet<School> Schools => Set<School>();
public DbSet<Major> Majors => Set<Major>();
public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
public DbSet<Question> Questions => Set<Question>();
public DbSet<QuestionVersion> QuestionVersions => Set<QuestionVersion>();
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
public DbSet<RecentPractice> RecentPractices => Set<RecentPractice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("citext");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
modelBuilder.UseSnakeCaseIdentifiers();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
UpdateTimestamps();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default)
{
UpdateTimestamps();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void UpdateTimestamps()
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
}
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}