feat: add scoreline dynamic records
This commit is contained in:
@@ -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; }
|
||||
|
||||
46
Tiku.Api/Contracts/ScorelineDtos.cs
Normal file
46
Tiku.Api/Contracts/ScorelineDtos.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
126
Tiku.Api/Controllers/ScorelineController.cs
Normal file
126
Tiku.Api/Controllers/ScorelineController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
60
Tiku.Application/Scoreline/ScorelineModels.cs
Normal file
60
Tiku.Application/Scoreline/ScorelineModels.cs
Normal 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);
|
||||
}
|
||||
34
Tiku.Domain/Catalog/ScorelineEntities.cs
Normal file
34
Tiku.Domain/Catalog/ScorelineEntities.cs
Normal 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();
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
15554
Tiku.Infrastructure/Persistence/Migrations/20260726102813_AddScorelineDynamicRecords.Designer.cs
generated
Normal file
15554
Tiku.Infrastructure/Persistence/Migrations/20260726102813_AddScorelineDynamicRecords.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
268
Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs
Normal file
268
Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
97
Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs
Normal file
97
Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs
Normal 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}®ionId={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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user