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, ScorelineRecordQuery recordQuery) : IScorelineQueryService { public async Task> 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(fields.Select(ToFieldItem).ToArray()); } public async Task GetRecordsAsync( ScorelineFilter filter, CancellationToken cancellationToken = default) { var page = Math.Max(filter.Page ?? 1, 1); var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200); ValidateDynamicFilterKeys(filter.DynamicFilters); var result = await recordQuery.ExecutePageAsync(filter, page, pageSize, cancellationToken); return new ScorelineRecordPage(page, pageSize, result.Total ?? 0, result.Items); } public async Task 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> GetTrendAsync( ScorelineFilter filter, CancellationToken cancellationToken = default) { 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(result.Items .OrderBy(item => item.Year) .ThenBy(item => item.MajorName) .ToArray()); } public async Task> 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(years); } private IQueryable 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 static void ValidateDynamicFilterKeys(IReadOnlyCollection? filters) { 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 (dynamicFilter.Operator is not ("field" or "min" or "max")) throw new ScorelineQueryException("Scoreline field filter operator is invalid.", "scoreline_field_filter_operator_invalid"); } } private static string EncodeCursor(ScorelineRecordItem item) { var json = JsonSerializer.SerializeToUtf8Bytes(new { v = 1, year = item.Year, schoolName = item.SchoolName, majorName = item.MajorName, id = item.Id }); return Convert.ToBase64String(json).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } private static ScorelineCursorPosition? DecodeCursor(string? cursor) { if (string.IsNullOrWhiteSpace(cursor)) return null; try { 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(); 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) { return root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; } 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(); }