perf: optimize authorization scoreline and workers
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
@@ -8,7 +9,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Scoreline;
|
||||
|
||||
public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : IScorelineQueryService
|
||||
public sealed partial class ScorelineQueryService(
|
||||
TikuDbContext dbContext,
|
||||
ScorelineRecordQuery recordQuery) : IScorelineQueryService
|
||||
{
|
||||
public async Task<CatalogList<ScorelineFieldItem>> GetFieldsAsync(
|
||||
ScorelineFilter filter,
|
||||
@@ -36,33 +39,37 @@ public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : ISc
|
||||
{
|
||||
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);
|
||||
ValidateDynamicFilterKeys(filter.DynamicFilters);
|
||||
var result = await recordQuery.ExecutePageAsync(filter, page, pageSize, cancellationToken);
|
||||
return new ScorelineRecordPage(page, pageSize, result.Total ?? 0, result.Items);
|
||||
}
|
||||
|
||||
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<ScorelineRecordCursorPage> GetRecordsCursorAsync(
|
||||
ScorelineFilter filter,
|
||||
string? cursor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateDynamicFilterKeys(filter.DynamicFilters);
|
||||
var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200);
|
||||
var position = DecodeCursor(cursor);
|
||||
var result = await recordQuery.ExecuteCursorAsync(filter, pageSize, position, cancellationToken);
|
||||
var hasMore = result.Items.Count > pageSize;
|
||||
var items = result.Items.Take(pageSize).ToArray();
|
||||
var nextCursor = hasMore && items.Length > 0 ? EncodeCursor(items[^1]) : null;
|
||||
return new ScorelineRecordCursorPage(pageSize, hasMore, nextCursor, 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());
|
||||
ValidateDynamicFilterKeys(filter.DynamicFilters);
|
||||
var trendFilter = filter with { Page = 1, PageSize = ResolveLimit(filter.Limit, 100) };
|
||||
var result = await recordQuery.ExecutePageAsync(trendFilter, 1, trendFilter.PageSize!.Value, cancellationToken);
|
||||
return new CatalogList<ScorelineRecordItem>(result.Items
|
||||
.OrderBy(item => item.Year)
|
||||
.ThenBy(item => item.MajorName)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<CatalogList<int>> GetYearsAsync(
|
||||
@@ -114,111 +121,73 @@ public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : ISc
|
||||
return query;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScorelineRecord>> ApplyDynamicFiltersAsync(
|
||||
ScorelineFilter filter,
|
||||
IReadOnlyCollection<ScorelineRecord> records,
|
||||
CancellationToken cancellationToken)
|
||||
private static void ValidateDynamicFilterKeys(IReadOnlyCollection<ScorelineDynamicFilter>? filters)
|
||||
{
|
||||
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)
|
||||
foreach (var dynamicFilter in filters ?? [])
|
||||
{
|
||||
if (!FieldKeyRegex().IsMatch(dynamicFilter.FieldKey))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter key is invalid.", "scoreline_field_filter_key_invalid");
|
||||
}
|
||||
|
||||
if (!allowed.ContainsKey(dynamicFilter.FieldKey))
|
||||
if (dynamicFilter.Operator is not ("field" or "min" or "max"))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed");
|
||||
throw new ScorelineQueryException("Scoreline field filter operator is invalid.", "scoreline_field_filter_operator_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private static string EncodeCursor(ScorelineRecordItem item)
|
||||
{
|
||||
if (record.FieldValues.ValueKind != JsonValueKind.Object ||
|
||||
!record.FieldValues.TryGetProperty(filter.FieldKey, out var value))
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
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)
|
||||
};
|
||||
v = 1,
|
||||
year = item.Year,
|
||||
schoolName = item.SchoolName,
|
||||
majorName = item.MajorName,
|
||||
id = item.Id
|
||||
});
|
||||
return Convert.ToBase64String(json).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static bool IsNumericType(string fieldType)
|
||||
private static ScorelineCursorPosition? DecodeCursor(string? cursor)
|
||||
{
|
||||
return fieldType is "number" or "integer" or "decimal" or "float";
|
||||
}
|
||||
|
||||
private static bool TryGetDecimal(JsonElement value, out decimal result)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Number)
|
||||
if (string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
return value.TryGetDecimal(out result);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String)
|
||||
try
|
||||
{
|
||||
return decimal.TryParse(value.GetString(), out result);
|
||||
}
|
||||
var normalized = cursor.Replace('-', '+').Replace('_', '/');
|
||||
normalized = normalized.PadRight(normalized.Length + ((4 - normalized.Length % 4) % 4), '=');
|
||||
using var document = JsonDocument.Parse(Convert.FromBase64String(normalized));
|
||||
var root = document.RootElement;
|
||||
if (root.GetProperty("v").GetInt32() != 1 ||
|
||||
!root.TryGetProperty("year", out var year) ||
|
||||
!root.TryGetProperty("id", out var id) ||
|
||||
!Guid.TryParse(id.GetString(), out var recordId))
|
||||
{
|
||||
throw new FormatException();
|
||||
}
|
||||
|
||||
result = 0;
|
||||
return false;
|
||||
return new ScorelineCursorPosition(
|
||||
year.GetInt32(),
|
||||
ReadNullableString(root, "schoolName"),
|
||||
ReadNullableString(root, "majorName"),
|
||||
recordId);
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or JsonException or InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline cursor is invalid or unsupported.", "scoreline_cursor_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadNullableString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static ScorelineFieldItem ToFieldItem(ScorelineField field)
|
||||
{
|
||||
return new ScorelineFieldItem(
|
||||
|
||||
Reference in New Issue
Block a user