269 lines
9.4 KiB
C#
269 lines
9.4 KiB
C#
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;
|
|
}
|