feat: add scoreline dynamic records
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user