396 lines
12 KiB
C#
396 lines
12 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Assets;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.QuestionBanks;
|
|
|
|
namespace Tiku.Infrastructure.Content;
|
|
|
|
public sealed partial class DirectContentService
|
|
{
|
|
private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version)
|
|
{
|
|
return new QuestionManagementItem(
|
|
question.Id,
|
|
version?.Id,
|
|
question.QuestionBankId,
|
|
question.SubjectId,
|
|
question.CategoryId,
|
|
question.NodeId,
|
|
question.EntryId,
|
|
question.ContentNodeId,
|
|
question.PrimaryCollectionId,
|
|
question.LegacyId,
|
|
question.Type,
|
|
question.TypeLabel,
|
|
question.Difficulty,
|
|
question.Tags,
|
|
version?.Content,
|
|
version?.Options ?? JsonDefaults.Array(),
|
|
version?.CorrectOptionIndex,
|
|
version?.CorrectOptionIndices ?? JsonDefaults.Array(),
|
|
version?.AnswerText,
|
|
version?.Explanation,
|
|
version?.SubQuestions ?? JsonDefaults.Array(),
|
|
version?.CodeLang,
|
|
version?.CodeTemplate,
|
|
question.MediaUrl,
|
|
question.HasVideoExplanation,
|
|
question.Status);
|
|
}
|
|
|
|
private static VideoManagementItem ToVideoItem(VideoExplanation item)
|
|
{
|
|
return new VideoManagementItem(
|
|
item.Id,
|
|
item.SubjectId,
|
|
item.LegacyId,
|
|
item.Title,
|
|
item.Description,
|
|
item.VideoUrl,
|
|
item.ThumbnailUrl,
|
|
item.DurationSeconds,
|
|
item.KnowledgeTags,
|
|
item.IsGeneral,
|
|
item.Difficulty,
|
|
item.SortOrder,
|
|
item.IsActive,
|
|
item.Metadata);
|
|
}
|
|
|
|
private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item)
|
|
{
|
|
return new QuestionVideoManagementItem(
|
|
item.Id,
|
|
item.QuestionId,
|
|
item.VideoId,
|
|
item.LegacyId,
|
|
item.VideoType,
|
|
item.SortOrder,
|
|
item.Metadata);
|
|
}
|
|
|
|
private static OperationContentItem ToOperationItem(Banner item)
|
|
{
|
|
return new OperationContentItem(
|
|
item.Id,
|
|
"banners",
|
|
item.RegionId,
|
|
null,
|
|
item.LegacyId,
|
|
item.Title,
|
|
item.Content,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
item.SortOrder,
|
|
item.IsActive,
|
|
JsonSerializer.SerializeToElement(new
|
|
{
|
|
item.Subtitle,
|
|
item.ButtonText,
|
|
item.ButtonLink,
|
|
item.BackgroundColor,
|
|
item.BorderColor
|
|
}));
|
|
}
|
|
|
|
private static OperationContentItem ToOperationItem(Faq item)
|
|
{
|
|
return new OperationContentItem(
|
|
item.Id,
|
|
"faqs",
|
|
item.RegionId,
|
|
null,
|
|
item.LegacyId,
|
|
null,
|
|
null,
|
|
item.Question,
|
|
item.Answer,
|
|
null,
|
|
null,
|
|
item.SortOrder,
|
|
item.IsActive,
|
|
JsonDefaults.Object());
|
|
}
|
|
|
|
private static OperationContentItem ToOperationItem(Announcement item)
|
|
{
|
|
return new OperationContentItem(
|
|
item.Id,
|
|
"announcements",
|
|
null,
|
|
null,
|
|
item.LegacyId,
|
|
null,
|
|
item.Content,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
item.SortOrder,
|
|
item.IsActive,
|
|
JsonSerializer.SerializeToElement(new
|
|
{
|
|
item.Link,
|
|
item.BackgroundColor
|
|
}));
|
|
}
|
|
|
|
private static OperationContentItem ToOperationItem(ExamDate item)
|
|
{
|
|
return new OperationContentItem(
|
|
item.Id,
|
|
"exam-dates",
|
|
item.RegionId,
|
|
item.SchoolId,
|
|
item.LegacyId,
|
|
item.ExamName,
|
|
item.Description,
|
|
null,
|
|
null,
|
|
item.ExamAt,
|
|
item.ExamType,
|
|
item.SortOrder,
|
|
item.IsActive,
|
|
item.Metadata);
|
|
}
|
|
|
|
private static ContentImportJobItem ToJobItem(ContentImportJob job)
|
|
{
|
|
return new ContentImportJobItem(
|
|
job.Id,
|
|
job.TargetRegionId,
|
|
job.TargetSubjectId,
|
|
job.TargetCategoryId,
|
|
job.TargetContentNodeId,
|
|
job.TargetQuestionBankId,
|
|
job.ImportType,
|
|
job.SourceFormat,
|
|
job.Status,
|
|
job.SourceName,
|
|
job.SourceHash,
|
|
job.DryRun,
|
|
job.TotalCount,
|
|
job.ValidCount,
|
|
job.ErrorCount,
|
|
job.WarningCount,
|
|
job.InsertedCount,
|
|
job.UpdatedCount,
|
|
job.SkippedCount,
|
|
job.Summary,
|
|
job.ErrorMessage,
|
|
job.StartedAt,
|
|
job.FinishedAt,
|
|
job.CreatedAt,
|
|
job.UpdatedAt);
|
|
}
|
|
|
|
private static ContentImportItemModel ToImportItem(ContentImportItem item)
|
|
{
|
|
return new ContentImportItemModel(
|
|
item.Id,
|
|
item.JobId,
|
|
item.RowNo,
|
|
item.ExternalId,
|
|
item.Status,
|
|
item.TargetType,
|
|
item.TargetId,
|
|
item.SourcePayload,
|
|
item.NormalizedPayload,
|
|
item.ContentHash,
|
|
item.IssuesCount);
|
|
}
|
|
|
|
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
|
DirectContentActor actor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
|
if (!access.IsCurrentTenantMember ||
|
|
access.UserId != actor.UserId ||
|
|
access.TenantId != actor.TenantId ||
|
|
!ContentPermissions.Any(access.HasTenantPermission))
|
|
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
|
|
|
|
return access.DataScope;
|
|
}
|
|
|
|
private static void EnsureRegionWriteAllowed(
|
|
CurrentDataScope scope,
|
|
DirectContentActor actor,
|
|
Guid? currentRegionId,
|
|
Guid? targetRegionId,
|
|
bool isNew,
|
|
string notFoundCode)
|
|
{
|
|
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
|
|
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
|
|
if (!canAccessCurrent || !canAccessTarget)
|
|
throw new ContentManagementException("Content resource was not found.", notFoundCode);
|
|
}
|
|
|
|
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
|
|
DbSet<TEntity> set,
|
|
Guid tenantId,
|
|
Guid? id,
|
|
string? legacyId,
|
|
CancellationToken cancellationToken)
|
|
where TEntity : AuditableTenantEntity
|
|
{
|
|
if (id.HasValue)
|
|
return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value,
|
|
cancellationToken);
|
|
|
|
var normalizedLegacyId = Normalize(legacyId);
|
|
return normalizedLegacyId is null
|
|
? null
|
|
: await set.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && EF.Property<string?>(item, "LegacyId") == normalizedLegacyId,
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task AssertReferenceAsync<TEntity>(
|
|
Guid tenantId,
|
|
Guid? id,
|
|
string code,
|
|
CancellationToken cancellationToken)
|
|
where TEntity : class
|
|
{
|
|
if (!id.HasValue) return;
|
|
|
|
var exists = await dbContext.Set<TEntity>()
|
|
.AnyAsync(
|
|
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
|
|
cancellationToken);
|
|
if (!exists) throw new ContentManagementException("Referenced entity was not found.", code);
|
|
}
|
|
|
|
private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await dbContext.ContentImportJobs.AnyAsync(
|
|
item => item.TenantId == tenantId && item.Id == jobId,
|
|
cancellationToken);
|
|
if (!exists) throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
|
}
|
|
|
|
private static string NormalizeOperationKind(string kind)
|
|
{
|
|
var normalized = Normalize(kind)?.ToLowerInvariant();
|
|
return normalized switch
|
|
{
|
|
"banner" or "banners" => "banners",
|
|
"faq" or "faqs" => "faqs",
|
|
"announcement" or "announcements" => "announcements",
|
|
"exam-date" or "exam-dates" or "examdates" => "exam-dates",
|
|
_ => normalized ?? string.Empty
|
|
};
|
|
}
|
|
|
|
private static ContentImportType ParseImportType(string value)
|
|
{
|
|
return value.ToLowerInvariant() switch
|
|
{
|
|
"questions" => ContentImportType.Questions,
|
|
"vocabulary" => ContentImportType.Vocabulary,
|
|
"handbook" => ContentImportType.Handbook,
|
|
"scoreline" => ContentImportType.Scoreline,
|
|
"videos" => ContentImportType.Videos,
|
|
_ => throw new ContentManagementException("Import type is invalid.", "import_type_invalid")
|
|
};
|
|
}
|
|
|
|
private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
|
|
where TEnum : struct
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return fallback;
|
|
|
|
if (Enum.TryParse<TEnum>(value.Trim(), true, out var parsed)) return parsed;
|
|
|
|
throw new ContentManagementException("Enum value is invalid.", code);
|
|
}
|
|
|
|
private static TEnum? ParseNullable<TEnum>(string? value, string code)
|
|
where TEnum : struct
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
|
|
if (Enum.TryParse<TEnum>(value.Trim(), true, out var parsed)) return parsed;
|
|
|
|
throw new ContentManagementException("Enum value is invalid.", code);
|
|
}
|
|
|
|
private static string? Normalize(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
private static int ResolveLimit(int? limit)
|
|
{
|
|
return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit);
|
|
}
|
|
|
|
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
|
{
|
|
return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object();
|
|
}
|
|
|
|
private static JsonElement JsonArrayOrDefault(JsonElement value)
|
|
{
|
|
return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array();
|
|
}
|
|
|
|
private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback)
|
|
{
|
|
return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value)
|
|
? value
|
|
: fallback;
|
|
}
|
|
|
|
private static string? GetString(JsonElement payload, string name)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
|
|
|
|
return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString();
|
|
}
|
|
|
|
private static int? GetInt(JsonElement payload, string name)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
|
|
|
|
return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)
|
|
? number
|
|
: int.TryParse(value.ToString(), out number)
|
|
? number
|
|
: 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)) return null;
|
|
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed,
|
|
_ => null
|
|
};
|
|
}
|
|
} |