feat: add scoreline dynamic records

This commit is contained in:
xiong
2026-07-26 18:30:15 +08:00
parent fcf70db8aa
commit 5715869d29
19 changed files with 17069 additions and 13 deletions

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
@@ -17,6 +18,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled);
private static readonly HashSet<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
{
"questions",
@@ -609,13 +611,151 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
return new ContentManagementResult<Major>(item);
}
public async Task<CatalogList<ScorelineField>> GetScorelineFieldsAsync(
DirectContentActor actor,
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item => item.FieldKey.Contains(keyword) || item.FieldName.Contains(keyword));
}
return new CatalogList<ScorelineField>(await query
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.FieldName)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken));
}
public async Task<ContentManagementResult<ScorelineField>> UpsertScorelineFieldAsync(
DirectContentActor actor,
ScorelineFieldCommand command,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
{
throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid");
}
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
item.FieldKey = command.FieldKey.Trim();
item.FieldName = command.FieldName.Trim();
item.FieldType = Normalize(command.FieldType) ?? "text";
item.Unit = Normalize(command.Unit);
item.IsFilter = command.IsFilter ?? item.IsFilter;
item.IsRequired = command.IsRequired ?? item.IsRequired;
item.IsVisible = command.IsVisible ?? item.IsVisible;
item.IsTrend = command.IsTrend ?? item.IsTrend;
item.Options = JsonArrayOrDefault(command.Options);
item.Placeholder = Normalize(command.Placeholder);
item.Description = Normalize(command.Description);
item.SortOrder = command.Order ?? item.SortOrder;
if (isNew)
{
dbContext.ScorelineFields.Add(item);
}
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<ScorelineField>(item);
}
public async Task<CatalogList<ScorelineRecord>> GetScorelineRecordsAsync(
DirectContentActor actor,
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
}
if (filter.SchoolId.HasValue)
{
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
}
if (filter.MajorId.HasValue)
{
query = query.Where(item => item.MajorId == filter.MajorId.Value);
}
if (filter.Year.HasValue)
{
query = query.Where(item => item.Year == filter.Year.Value);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item =>
(item.SchoolName != null && item.SchoolName.Contains(keyword)) ||
(item.MajorName != null && item.MajorName.Contains(keyword)));
}
return new CatalogList<ScorelineRecord>(await query
.OrderByDescending(item => item.Year)
.ThenBy(item => item.SchoolName)
.ThenBy(item => item.MajorName)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken));
}
public async Task<ContentManagementResult<ScorelineRecord>> UpsertScorelineRecordAsync(
DirectContentActor actor,
ScorelineRecordCommand command,
CancellationToken cancellationToken = default)
{
if (command.Year is < 1900 or > 3000)
{
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
}
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
item.MajorId = command.MajorId;
item.LegacyId = Normalize(command.LegacyId);
item.Year = command.Year;
item.SchoolName = Normalize(command.SchoolName);
item.MajorName = Normalize(command.MajorName);
item.FieldValues = JsonObjectOrDefault(command.FieldValues);
if (isNew)
{
dbContext.ScorelineRecords.Add(item);
}
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<ScorelineRecord>(item);
}
public async Task<CatalogList<int>> GetScorelineYearsAsync(
DirectContentActor actor,
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ExamDates.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.ExamAt.HasValue);
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -627,7 +767,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
}
var years = await query
.Select(item => item.ExamAt!.Value.Year)
.Select(item => item.Year)
.Distinct()
.OrderByDescending(year => year)
.Take(ResolveLimit(filter.Limit))
@@ -644,14 +784,17 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
var items = new List<ScorelineTrendItem>();
foreach (var year in years.Items)
{
var schoolCount = await dbContext.ExamDates.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.ExamAt.HasValue && item.ExamAt.Value.Year == year)
var schoolCount = await dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Year == year)
.Select(item => item.SchoolId)
.Where(id => id.HasValue)
.Distinct()
.CountAsync(cancellationToken);
var majorCount = await dbContext.Majors.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.IsActive)
var majorCount = await dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Year == year)
.Select(item => item.MajorId)
.Where(id => id.HasValue)
.Distinct()
.CountAsync(cancellationToken);
items.Add(new ScorelineTrendItem(year, schoolCount, majorCount));
}
@@ -1072,14 +1215,17 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
return ("handbook_entry", entry.Item.Id);
case "scoreline":
var school = await UpsertSchoolAsync(actor, new SchoolCommand(
var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand(
null,
command.RegionId,
GetGuid(payload, "schoolId"),
GetGuid(payload, "majorId"),
GetString(payload, "legacyId"),
GetString(payload, "schoolName") ?? GetString(payload, "name") ?? "未命名学校",
GetString(payload, "professionalExamDate"),
payload), cancellationToken);
return ("school", school.Item.Id);
GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year,
GetString(payload, "schoolName"),
GetString(payload, "majorName"),
GetElement(payload, "fieldValues", payload)), cancellationToken);
return ("scoreline_record", scoreline.Item.Id);
case "videos":
var video = await UpsertVideoAsync(actor, new VideoExplanationCommand(
null,
@@ -1722,6 +1868,19 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
: null;
}
private static Guid? GetGuid(JsonElement payload, string name)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String &&
Guid.TryParse(value.GetString(), out var guid)
? guid
: null;
}
private static bool? GetBool(JsonElement payload, string name)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))

View File

@@ -7,6 +7,7 @@ using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Scoreline;
using Tiku.Application.Storage;
using Tiku.Application.StudyContent;
using Tiku.Infrastructure.Assets;
@@ -16,6 +17,7 @@ using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.StudyContent;
@@ -47,6 +49,7 @@ public static class DependencyInjection
services.AddScoped<IContentManagementService, ContentManagementService>();
services.AddScoped<IDirectContentService, DirectContentService>();
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
services.AddScoped<IAssetQueryService, AssetQueryService>();
services.AddScoped<IAssetAccessService, AssetAccessService>();

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class ScorelineFieldConfiguration : IEntityTypeConfiguration<ScorelineField>
{
public void Configure(EntityTypeBuilder<ScorelineField> builder)
{
builder.ConfigureTenantEntity("scoreline_fields");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.FieldKey).HasMaxLength(64);
builder.Property(entity => entity.FieldName).HasMaxLength(200);
builder.Property(entity => entity.FieldType).HasMaxLength(32);
builder.Property(entity => entity.Unit).HasMaxLength(32);
builder.Property(entity => entity.Options).IsJson("[]");
builder.Property(entity => entity.Placeholder).HasMaxLength(200);
builder.Property(entity => entity.Description).HasMaxLength(1000);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.FieldKey }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.IsFilter, entity.SortOrder });
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class ScorelineRecordConfiguration : IEntityTypeConfiguration<ScorelineRecord>
{
public void Configure(EntityTypeBuilder<ScorelineRecord> builder)
{
builder.ConfigureTenantEntity("scoreline_records");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.SchoolName).HasMaxLength(300);
builder.Property(entity => entity.MajorName).HasMaxLength(300);
builder.Property(entity => entity.FieldValues).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year });
builder.HasIndex(entity => new { entity.TenantId, entity.Year });
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);
builder.HasOne<Major>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.MajorId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddScorelineDynamicRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "scoreline_fields",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
field_key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
field_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
field_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
unit = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
is_filter = table.Column<bool>(type: "boolean", nullable: false),
is_required = table.Column<bool>(type: "boolean", nullable: false),
is_visible = table.Column<bool>(type: "boolean", nullable: false),
is_trend = table.Column<bool>(type: "boolean", nullable: false),
options = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
placeholder = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_scoreline_fields", x => x.id);
table.UniqueConstraint("ak_scoreline_fields_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_scoreline_fields_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_fields_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "scoreline_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
school_id = table.Column<Guid>(type: "uuid", nullable: true),
major_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
year = table.Column<int>(type: "integer", nullable: false),
school_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
major_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
field_values = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_scoreline_records", x => x.id);
table.UniqueConstraint("ak_scoreline_records_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_scoreline_records_majors_tenant_id_major_id",
columns: x => new { x.tenant_id, x.major_id },
principalTable: "majors",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_schools_tenant_id_school_id",
columns: x => new { x.tenant_id, x.school_id },
principalTable: "schools",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_legacy_id",
table: "scoreline_fields",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_region_id_field_key",
table: "scoreline_fields",
columns: new[] { "tenant_id", "region_id", "field_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order",
table: "scoreline_fields",
columns: new[] { "tenant_id", "region_id", "is_filter", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_legacy_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_major_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "major_id" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~",
table: "scoreline_records",
columns: new[] { "tenant_id", "region_id", "school_id", "major_id", "year" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_school_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "school_id" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_year",
table: "scoreline_records",
columns: new[] { "tenant_id", "year" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "scoreline_fields");
migrationBuilder.DropTable(
name: "scoreline_records");
}
}
}

View File

@@ -546,6 +546,204 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("schools", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<string>("FieldKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("field_key");
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("field_name");
b.Property<string>("FieldType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("field_type");
b.Property<bool>("IsFilter")
.HasColumnType("boolean")
.HasColumnName("is_filter");
b.Property<bool>("IsRequired")
.HasColumnType("boolean")
.HasColumnName("is_required");
b.Property<bool>("IsTrend")
.HasColumnType("boolean")
.HasColumnName("is_trend");
b.Property<bool>("IsVisible")
.HasColumnType("boolean")
.HasColumnName("is_visible");
b.Property<string>("LegacyId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("legacy_id");
b.Property<JsonElement>("Options")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("options")
.HasDefaultValueSql("'[]'::jsonb");
b.Property<string>("Placeholder")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("placeholder");
b.Property<Guid?>("RegionId")
.HasColumnType("uuid")
.HasColumnName("region_id");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("Unit")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("unit");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_scoreline_fields");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_scoreline_fields_tenant_id_id");
b.HasIndex("TenantId", "LegacyId")
.IsUnique()
.HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id");
b.HasIndex("TenantId", "RegionId", "FieldKey")
.IsUnique()
.HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key");
b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder")
.HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order");
b.ToTable("scoreline_fields", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("FieldValues")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("field_values")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("LegacyId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("legacy_id");
b.Property<Guid?>("MajorId")
.HasColumnType("uuid")
.HasColumnName("major_id");
b.Property<string>("MajorName")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("major_name");
b.Property<Guid?>("RegionId")
.HasColumnType("uuid")
.HasColumnName("region_id");
b.Property<Guid?>("SchoolId")
.HasColumnType("uuid")
.HasColumnName("school_id");
b.Property<string>("SchoolName")
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("school_name");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<int>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("Id")
.HasName("pk_scoreline_records");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_scoreline_records_tenant_id_id");
b.HasIndex("TenantId", "LegacyId")
.IsUnique()
.HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id");
b.HasIndex("TenantId", "MajorId")
.HasDatabaseName("ix_scoreline_records_tenant_id_major_id");
b.HasIndex("TenantId", "SchoolId")
.HasDatabaseName("ix_scoreline_records_tenant_id_school_id");
b.HasIndex("TenantId", "Year")
.HasDatabaseName("ix_scoreline_records_tenant_id_year");
b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year")
.HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~");
b.ToTable("scoreline_records", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
{
b.Property<Guid>("Id")
@@ -12499,6 +12697,54 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_schools_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_scoreline_fields_tenants_tenant_id");
b.HasOne("Tiku.Domain.Catalog.Region", null)
.WithMany()
.HasForeignKey("TenantId", "RegionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_scoreline_fields_regions_tenant_id_region_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_scoreline_records_tenants_tenant_id");
b.HasOne("Tiku.Domain.Catalog.Major", null)
.WithMany()
.HasForeignKey("TenantId", "MajorId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_scoreline_records_majors_tenant_id_major_id");
b.HasOne("Tiku.Domain.Catalog.Region", null)
.WithMany()
.HasForeignKey("TenantId", "RegionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_scoreline_records_regions_tenant_id_region_id");
b.HasOne("Tiku.Domain.Catalog.School", null)
.WithMany()
.HasForeignKey("TenantId", "SchoolId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_scoreline_records_schools_tenant_id_school_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -41,6 +41,8 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<Major> Majors => Set<Major>();
public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<ScorelineField> ScorelineFields => Set<ScorelineField>();
public DbSet<ScorelineRecord> ScorelineRecords => Set<ScorelineRecord>();
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
public DbSet<Question> Questions => Set<Question>();
public DbSet<QuestionVersion> QuestionVersions => Set<QuestionVersion>();

View File

@@ -0,0 +1,268 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Scoreline;
using Tiku.Domain.Catalog;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Scoreline;
public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : IScorelineQueryService
{
public async Task<CatalogList<ScorelineFieldItem>> GetFieldsAsync(
ScorelineFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineFields.AsNoTracking()
.Where(field => field.TenantId == filter.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(field => field.RegionId == filter.RegionId.Value || field.RegionId == null);
}
var fields = await query
.OrderBy(field => field.SortOrder)
.ThenBy(field => field.FieldName)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
return new CatalogList<ScorelineFieldItem>(fields.Select(ToFieldItem).ToArray());
}
public async Task<ScorelineRecordPage> GetRecordsAsync(
ScorelineFilter filter,
CancellationToken cancellationToken = default)
{
var page = Math.Max(filter.Page ?? 1, 1);
var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200);
var records = await BuildRecordQuery(filter)
.OrderByDescending(record => record.Year)
.ThenBy(record => record.SchoolName)
.ThenBy(record => record.MajorName)
.ToArrayAsync(cancellationToken);
var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken);
var items = filtered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(ToRecordItem)
.ToArray();
return new ScorelineRecordPage(page, pageSize, filtered.Count, items);
}
public async Task<CatalogList<ScorelineRecordItem>> GetTrendAsync(
ScorelineFilter filter,
CancellationToken cancellationToken = default)
{
var records = await BuildRecordQuery(filter)
.OrderBy(record => record.Year)
.ThenBy(record => record.MajorName)
.Take(ResolveLimit(filter.Limit, 100))
.ToArrayAsync(cancellationToken);
var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken);
return new CatalogList<ScorelineRecordItem>(filtered.Select(ToRecordItem).ToArray());
}
public async Task<CatalogList<int>> GetYearsAsync(
ScorelineFilter filter,
CancellationToken cancellationToken = default)
{
var years = await BuildRecordQuery(filter)
.Select(record => record.Year)
.Distinct()
.OrderByDescending(year => year)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
return new CatalogList<int>(years);
}
private IQueryable<ScorelineRecord> BuildRecordQuery(ScorelineFilter filter)
{
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(record => record.TenantId == filter.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(record => record.RegionId == filter.RegionId.Value);
}
if (filter.SchoolId.HasValue)
{
query = query.Where(record => record.SchoolId == filter.SchoolId.Value);
}
if (filter.MajorId.HasValue)
{
query = query.Where(record => record.MajorId == filter.MajorId.Value);
}
if (filter.Year.HasValue)
{
query = query.Where(record => record.Year == filter.Year.Value);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(record =>
(record.SchoolName != null && record.SchoolName.Contains(keyword)) ||
(record.MajorName != null && record.MajorName.Contains(keyword)));
}
return query;
}
private async Task<IReadOnlyList<ScorelineRecord>> ApplyDynamicFiltersAsync(
ScorelineFilter filter,
IReadOnlyCollection<ScorelineRecord> records,
CancellationToken cancellationToken)
{
if (filter.DynamicFilters is null || filter.DynamicFilters.Count == 0)
{
return records.ToArray();
}
var allowedFields = await dbContext.ScorelineFields.AsNoTracking()
.Where(field =>
field.TenantId == filter.TenantId &&
field.IsFilter &&
(filter.RegionId == null || field.RegionId == filter.RegionId || field.RegionId == null))
.ToArrayAsync(cancellationToken);
var allowed = allowedFields
.GroupBy(field => field.FieldKey, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.OrderBy(field => field.SortOrder).First(), StringComparer.Ordinal);
foreach (var dynamicFilter in filter.DynamicFilters)
{
if (!FieldKeyRegex().IsMatch(dynamicFilter.FieldKey))
{
throw new ScorelineQueryException("Scoreline field filter key is invalid.", "scoreline_field_filter_key_invalid");
}
if (!allowed.ContainsKey(dynamicFilter.FieldKey))
{
throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed");
}
}
return records
.Where(record => filter.DynamicFilters.All(dynamicFilter => MatchDynamicFilter(record, allowed[dynamicFilter.FieldKey], dynamicFilter)))
.ToArray();
}
private static bool MatchDynamicFilter(
ScorelineRecord record,
ScorelineField field,
ScorelineDynamicFilter filter)
{
if (record.FieldValues.ValueKind != JsonValueKind.Object ||
!record.FieldValues.TryGetProperty(filter.FieldKey, out var value))
{
return false;
}
var fieldType = field.FieldType.Trim().ToLowerInvariant();
if (filter.Operator is "min" or "max")
{
if (!IsNumericType(fieldType))
{
throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", "scoreline_field_range_type_invalid");
}
if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected))
{
throw new ScorelineQueryException("Scoreline range filter value must be numeric.", "scoreline_field_range_value_invalid");
}
return filter.Operator == "min" ? actual >= expected : actual <= expected;
}
if (IsNumericType(fieldType))
{
if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected))
{
throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", "scoreline_field_value_invalid");
}
return actual == expected;
}
var actualText = value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
return fieldType switch
{
"text" or "textarea" or "string" => actualText?.Contains(filter.Value, StringComparison.OrdinalIgnoreCase) == true,
"boolean" or "bool" => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase),
_ => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase)
};
}
private static bool IsNumericType(string fieldType)
{
return fieldType is "number" or "integer" or "decimal" or "float";
}
private static bool TryGetDecimal(JsonElement value, out decimal result)
{
if (value.ValueKind == JsonValueKind.Number)
{
return value.TryGetDecimal(out result);
}
if (value.ValueKind == JsonValueKind.String)
{
return decimal.TryParse(value.GetString(), out result);
}
result = 0;
return false;
}
private static ScorelineFieldItem ToFieldItem(ScorelineField field)
{
return new ScorelineFieldItem(
field.Id,
field.LegacyId,
field.RegionId,
field.FieldKey,
field.FieldName,
field.FieldType,
field.Unit,
field.IsFilter,
field.IsRequired,
field.IsVisible,
field.IsTrend,
field.Options,
field.Placeholder,
field.Description,
field.SortOrder);
}
private static ScorelineRecordItem ToRecordItem(ScorelineRecord record)
{
return new ScorelineRecordItem(
record.Id,
record.LegacyId,
record.RegionId,
record.SchoolId,
record.MajorId,
record.Year,
record.SchoolName,
record.MajorName,
record.FieldValues);
}
private static int ResolveLimit(int? limit, int fallback = 500)
{
return Math.Clamp(limit ?? fallback, 1, 2000);
}
[GeneratedRegex("^[A-Za-z][A-Za-z0-9_]{0,63}$")]
private static partial Regex FieldKeyRegex();
}
public sealed class ScorelineQueryException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}