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

@@ -287,6 +287,75 @@ public sealed class DirectMajorDto
}
}
public sealed class DirectScorelineFieldDto
{
public Guid? Id { get; set; }
public Guid? RegionId { get; set; }
public string? LegacyId { get; set; }
public required string FieldKey { get; set; }
public required string FieldName { get; set; }
public string? FieldType { get; set; }
public string? Unit { get; set; }
public bool? IsFilter { get; set; }
public bool? IsRequired { get; set; }
public bool? IsVisible { get; set; }
public bool? IsTrend { get; set; }
public JsonElement Options { get; set; } = JsonDefaults.Array();
public string? Placeholder { get; set; }
public string? Description { get; set; }
public int? Order { get; set; }
public ScorelineFieldCommand ToCommand()
{
return new ScorelineFieldCommand(
Id,
RegionId,
LegacyId,
FieldKey,
FieldName,
FieldType,
Unit,
IsFilter,
IsRequired,
IsVisible,
IsTrend,
Options,
Placeholder,
Description,
Order);
}
}
public sealed class DirectScorelineRecordDto
{
public Guid? Id { get; set; }
public Guid? RegionId { get; set; }
public Guid? SchoolId { get; set; }
public Guid? MajorId { get; set; }
public string? LegacyId { get; set; }
[Range(1900, 3000)]
public int Year { get; set; }
public string? SchoolName { get; set; }
public string? MajorName { get; set; }
public JsonElement FieldValues { get; set; } = JsonDefaults.Object();
public ScorelineRecordCommand ToCommand()
{
return new ScorelineRecordCommand(
Id,
RegionId,
SchoolId,
MajorId,
LegacyId,
Year,
SchoolName,
MajorName,
FieldValues);
}
}
public sealed class DirectVideoDto
{
public Guid? Id { get; set; }

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using Tiku.Application.Scoreline;
namespace Tiku.Api.Contracts;
public sealed class ScorelineQueryDto
{
[StringLength(100)]
public string? TenantCode { get; set; }
public Guid? RegionId { get; set; }
public Guid? SchoolId { get; set; }
public Guid? MajorId { get; set; }
[StringLength(100)]
public string? Keyword { get; set; }
[Range(1900, 3000)]
public int? Year { get; set; }
[Range(1, 10000)]
public int? Page { get; set; }
[Range(1, 200)]
public int? PageSize { get; set; }
[Range(1, 2000)]
public int? Limit { get; set; }
public ScorelineFilter ToFilter(
Guid tenantId,
IReadOnlyCollection<ScorelineDynamicFilter>? dynamicFilters = null)
{
return new ScorelineFilter(
tenantId,
RegionId,
SchoolId,
MajorId,
Keyword,
Year,
Page,
PageSize,
Limit,
dynamicFilters);
}
}

View File

@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Scoreline;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Produces("application/json")]
[Route("api/scoreline")]
public sealed class ScorelineController(
IScorelineQueryService scorelineQueryService,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
{
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
[HttpGet("fields")]
[EndpointSummary("查询分数线字段配置")]
[ProducesResponseType<CatalogList<ScorelineFieldItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ScorelineFieldItem>>> GetFields(
[FromQuery] ScorelineQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await scorelineQueryService.GetFieldsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("records")]
[EndpointSummary("分页查询分数线记录")]
[ProducesResponseType<ScorelineRecordPage>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ScorelineRecordPage>> GetRecords(
[FromQuery] ScorelineQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await scorelineQueryService.GetRecordsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()),
cancellationToken));
}
[HttpGet("trend")]
[EndpointSummary("查询历年分数线趋势")]
[ProducesResponseType<CatalogList<ScorelineRecordItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ScorelineRecordItem>>> GetTrend(
[FromQuery] ScorelineQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await scorelineQueryService.GetTrendAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()),
cancellationToken));
}
[HttpGet("years")]
[EndpointSummary("查询分数线可用年份")]
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<int>>> GetYears(
[FromQuery] ScorelineQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await scorelineQueryService.GetYearsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
private IReadOnlyCollection<ScorelineDynamicFilter> ResolveDynamicFilters()
{
var filters = new List<ScorelineDynamicFilter>();
foreach (var (key, value) in Request.Query)
{
var prefix = DynamicPrefixes.FirstOrDefault(key.StartsWith);
if (prefix is null)
{
continue;
}
var raw = value.FirstOrDefault();
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
filters.Add(new ScorelineDynamicFilter(
prefix.TrimEnd('.'),
key[prefix.Length..],
raw.Trim()));
}
return filters;
}
private async Task<Guid> ResolveTenantIdAsync(
ScorelineQueryDto query,
CancellationToken cancellationToken)
{
if (currentTenant.TenantId.HasValue)
{
return currentTenant.TenantId.Value;
}
var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenantCode))
{
throw new TenantNotFoundException();
}
var tenantId = await dbContext.Tenants
.Where(tenant =>
tenant.Slug == tenantCode.Trim() &&
tenant.Status == TenantStatus.Active)
.Select(tenant => (Guid?)tenant.Id)
.SingleOrDefaultAsync(cancellationToken);
return tenantId ?? throw new TenantNotFoundException();
}
}

View File

@@ -179,6 +179,46 @@ public sealed class TenantContentDirectController(
return Ok(await directContentService.UpsertMajorAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("scoreline/fields")]
[EndpointSummary("查询管理侧分数线字段")]
[ProducesResponseType<CatalogList<ScorelineField>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineField>>> GetScorelineFields(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetScorelineFieldsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("scoreline/fields")]
[EndpointSummary("新增或更新动态分数线字段")]
[ProducesResponseType<ContentManagementResult<ScorelineField>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ScorelineField>>> UpsertScorelineField(
DirectScorelineFieldDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertScorelineFieldAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("scoreline/records")]
[EndpointSummary("查询管理侧分数线记录")]
[ProducesResponseType<CatalogList<ScorelineRecord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ScorelineRecord>>> GetScorelineRecords(
[FromQuery] DirectContentQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetScorelineRecordsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("scoreline/records")]
[EndpointSummary("新增或更新分数线记录")]
[ProducesResponseType<ContentManagementResult<ScorelineRecord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ScorelineRecord>>> UpsertScorelineRecord(
DirectScorelineRecordDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.UpsertScorelineRecordAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("scoreline/years")]
[EndpointSummary("查询分数线年份")]
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]

View File

@@ -7,6 +7,7 @@ using Tiku.Application.Storage;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
namespace Tiku.Api.Middleware;
@@ -139,6 +140,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is ScorelineQueryException scorelineQueryException)
{
await WriteProblemAsync(
context,
scorelineQueryException.Message,
StatusCodes.Status400BadRequest,
scorelineQueryException.Code);
return;
}
if (exception is ObjectStorageException storageException)
{
await WriteProblemAsync(

View File

@@ -176,6 +176,34 @@ public sealed record MajorCommand(
int? Order,
bool? IsActive);
public sealed record ScorelineFieldCommand(
Guid? Id,
Guid? RegionId,
string? LegacyId,
string FieldKey,
string FieldName,
string? FieldType,
string? Unit,
bool? IsFilter,
bool? IsRequired,
bool? IsVisible,
bool? IsTrend,
JsonElement Options,
string? Placeholder,
string? Description,
int? Order);
public sealed record ScorelineRecordCommand(
Guid? Id,
Guid? RegionId,
Guid? SchoolId,
Guid? MajorId,
string? LegacyId,
int Year,
string? SchoolName,
string? MajorName,
JsonElement FieldValues);
public sealed record VideoExplanationCommand(
Guid? Id,
Guid? SubjectId,
@@ -305,6 +333,10 @@ public interface IDirectContentService
Task<ContentManagementResult<School>> UpsertSchoolAsync(DirectContentActor actor, SchoolCommand command, CancellationToken cancellationToken = default);
Task<CatalogList<Major>> GetMajorsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
Task<ContentManagementResult<Major>> UpsertMajorAsync(DirectContentActor actor, MajorCommand command, CancellationToken cancellationToken = default);
Task<CatalogList<ScorelineField>> GetScorelineFieldsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
Task<ContentManagementResult<ScorelineField>> UpsertScorelineFieldAsync(DirectContentActor actor, ScorelineFieldCommand command, CancellationToken cancellationToken = default);
Task<CatalogList<ScorelineRecord>> GetScorelineRecordsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
Task<ContentManagementResult<ScorelineRecord>> UpsertScorelineRecordAsync(DirectContentActor actor, ScorelineRecordCommand command, CancellationToken cancellationToken = default);
Task<CatalogList<int>> GetScorelineYearsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
Task<CatalogList<ScorelineTrendItem>> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);
Task<CatalogList<VideoManagementItem>> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default);

View File

@@ -0,0 +1,60 @@
using System.Text.Json;
using Tiku.Application.Catalog;
namespace Tiku.Application.Scoreline;
public sealed record ScorelineFilter(
Guid TenantId,
Guid? RegionId = null,
Guid? SchoolId = null,
Guid? MajorId = null,
string? Keyword = null,
int? Year = null,
int? Page = null,
int? PageSize = null,
int? Limit = null,
IReadOnlyCollection<ScorelineDynamicFilter>? DynamicFilters = null);
public sealed record ScorelineDynamicFilter(string Operator, string FieldKey, string Value);
public sealed record ScorelineFieldItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
string FieldKey,
string FieldName,
string FieldType,
string? Unit,
bool IsFilter,
bool IsRequired,
bool IsVisible,
bool IsTrend,
JsonElement Options,
string? Placeholder,
string? Description,
int SortOrder);
public sealed record ScorelineRecordItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
Guid? SchoolId,
Guid? MajorId,
int Year,
string? SchoolName,
string? MajorName,
JsonElement FieldValues);
public sealed record ScorelineRecordPage(
int Page,
int PageSize,
int Total,
IReadOnlyCollection<ScorelineRecordItem> Items);
public interface IScorelineQueryService
{
Task<CatalogList<ScorelineFieldItem>> GetFieldsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
Task<ScorelineRecordPage> GetRecordsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
Task<CatalogList<ScorelineRecordItem>> GetTrendAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
Task<CatalogList<int>> GetYearsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,34 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Catalog;
public sealed class ScorelineField : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public string? LegacyId { get; set; }
public string FieldKey { get; set; } = string.Empty;
public string FieldName { get; set; } = string.Empty;
public string FieldType { get; set; } = "text";
public string? Unit { get; set; }
public bool IsFilter { get; set; }
public bool IsRequired { get; set; }
public bool IsVisible { get; set; } = true;
public bool IsTrend { get; set; }
public JsonElement Options { get; set; } = JsonDefaults.Array();
public string? Placeholder { get; set; }
public string? Description { get; set; }
public int SortOrder { get; set; } = 100;
}
public sealed class ScorelineRecord : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? SchoolId { get; set; }
public Guid? MajorId { get; set; }
public string? LegacyId { get; set; }
public int Year { get; set; }
public string? SchoolName { get; set; }
public string? MajorName { get; set; }
public JsonElement FieldValues { get; set; } = JsonDefaults.Object();
}

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;
}

View File

@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
@@ -174,6 +175,56 @@ public sealed class DirectContentEndpointTests
Assert.True(dbContext.Questions.Single(item => item.Id == questionId).HasVideoExplanation);
}
[Fact]
public async Task Tenant_admin_can_upsert_scoreline_fields_and_records()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var majorId = Guid.NewGuid();
await factory.SeedAsync(
new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" },
new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" },
new Major { Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" });
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var fieldResponse = await client.PutAsJsonAsync(
"/api/tenant-content/scoreline/fields",
new DirectScorelineFieldDto
{
RegionId = regionId,
FieldKey = "cultureScore",
FieldName = "文化分",
FieldType = "number",
IsFilter = true,
IsTrend = true
});
var recordResponse = await client.PutAsJsonAsync(
"/api/tenant-content/scoreline/records",
new DirectScorelineRecordDto
{
RegionId = regionId,
SchoolId = schoolId,
MajorId = majorId,
Year = 2026,
SchoolName = "美术学院",
MajorName = "视觉传达",
FieldValues = JsonSerializer.SerializeToElement(new { cultureScore = 420, rank = "A" })
});
var fieldsResponse = await client.GetAsync($"/api/tenant-content/scoreline/fields?regionId={regionId}");
var recordsResponse = await client.GetAsync($"/api/tenant-content/scoreline/records?regionId={regionId}&year=2026");
var fieldsJson = await ReadJsonAsync(fieldsResponse);
var recordsJson = await ReadJsonAsync(recordsResponse);
Assert.Equal(HttpStatusCode.OK, fieldResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, recordResponse.StatusCode);
Assert.Single(fieldsJson.RootElement.GetProperty("items").EnumerateArray());
Assert.Single(recordsJson.RootElement.GetProperty("items").EnumerateArray());
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class ScorelineEndpointTests
{
[Fact]
public async Task Public_scoreline_records_support_dynamic_filters()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var majorId = Guid.NewGuid();
var tenantCode = tenantId.ToString("N");
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantCode,
Name = "Test Tenant",
Status = TenantStatus.Active,
Metadata = JsonDefaults.Object()
},
new Region { Id = regionId, TenantId = tenantId, Name = "四川" },
new School { Id = schoolId, TenantId = tenantId, RegionId = regionId, Name = "美术学院" },
new Major { Id = majorId, TenantId = tenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" },
new ScorelineField
{
TenantId = tenantId,
RegionId = regionId,
FieldKey = "cultureScore",
FieldName = "文化分",
FieldType = "number",
IsFilter = true,
IsTrend = true
},
new ScorelineRecord
{
TenantId = tenantId,
RegionId = regionId,
SchoolId = schoolId,
MajorId = majorId,
Year = 2026,
SchoolName = "美术学院",
MajorName = "视觉传达",
FieldValues = JsonSerializer.SerializeToElement(new { cultureScore = 420, scoreType = "统考" })
},
new ScorelineRecord
{
TenantId = tenantId,
RegionId = regionId,
SchoolId = schoolId,
MajorId = majorId,
Year = 2025,
SchoolName = "美术学院",
MajorName = "视觉传达",
FieldValues = JsonSerializer.SerializeToElement(new { cultureScore = 360, scoreType = "统考" })
});
using var client = factory.CreateClient();
using var response = await client.GetAsync(
$"/api/scoreline/records?tenantCode={tenantCode}&regionId={regionId}&min.cultureScore=400");
var json = await ReadJsonAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(1, json.RootElement.GetProperty("total").GetInt32());
var item = json.RootElement.GetProperty("items").EnumerateArray().Single();
Assert.Equal(2026, item.GetProperty("year").GetInt32());
Assert.Equal(420, item.GetProperty("fieldValues").GetProperty("cultureScore").GetInt32());
}
[Fact]
public async Task Public_scoreline_without_tenant_returns_not_found()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/scoreline/fields");
var json = await ReadJsonAsync(response);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("tenant_not_found", json.RootElement.GetProperty("code").GetString());
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Content;
using Tiku.Domain.Growth;
@@ -30,7 +31,7 @@ public sealed class PersistenceModelTests
.Select(entity => entity.GetTableName())
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(124, tableNames.Count);
Assert.Equal(126, tableNames.Count);
Assert.Contains("tenants", tableNames);
Assert.Contains("tenant_settings", tableNames);
Assert.Contains("content_entries", tableNames);
@@ -135,6 +136,8 @@ public sealed class PersistenceModelTests
Assert.Contains("pb_import_issues", tableNames);
Assert.Contains("questions", tableNames);
Assert.Contains("question_versions", tableNames);
Assert.Contains("scoreline_fields", tableNames);
Assert.Contains("scoreline_records", tableNames);
Assert.Contains("answer_records", tableNames);
}
@@ -277,6 +280,41 @@ public sealed class PersistenceModelTests
properties => Assert.Contains("TenantId", properties));
}
[Theory]
[InlineData(typeof(ScorelineField), nameof(ScorelineField.Options), "'[]'::jsonb")]
[InlineData(typeof(ScorelineRecord), nameof(ScorelineRecord.FieldValues), "'{}'::jsonb")]
public void Scoreline_json_properties_are_mapped_to_jsonb(
Type entityType,
string propertyName,
string expectedDefaultValueSql)
{
using var context = new TikuDbContext(Options);
var property = context.Model.FindEntityType(entityType)!
.FindProperty(propertyName)!;
Assert.Equal("jsonb", property.GetColumnType());
Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
}
[Fact]
public void Scoreline_relations_include_tenant_in_foreign_keys()
{
using var context = new TikuDbContext(Options);
var foreignKeys = context.Model.FindEntityType(typeof(ScorelineRecord))!
.GetForeignKeys()
.Where(foreignKey => foreignKey.Properties.Count > 1)
.Select(foreignKey => foreignKey.Properties
.Select(property => property.Name)
.ToArray());
Assert.All(
foreignKeys,
properties => Assert.Contains("TenantId", properties));
}
[Fact]
public void Word_user_join_tables_have_tenant_scoped_unique_indexes()
{