467 lines
22 KiB
C#
467 lines
22 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Application.QuestionBanks;
|
|
using Tiku.Domain.Catalog;
|
|
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 async Task<SimpleImportResult> CreateImportJobAsync(
|
|
DirectContentActor actor,
|
|
SimpleImportCommand command,
|
|
bool execute,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!SupportedImportTypes.Contains(command.ImportType))
|
|
throw new ContentManagementException("Import type is invalid.", "import_type_invalid");
|
|
|
|
var importType = ParseImportType(command.ImportType);
|
|
var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid");
|
|
var items = command.Items
|
|
.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray();
|
|
var job = new ContentImportJob
|
|
{
|
|
TenantId = actor.TenantId,
|
|
CreatedBy = actor.UserId,
|
|
TargetRegionId = command.RegionId,
|
|
TargetSubjectId = command.SubjectId,
|
|
TargetCategoryId = command.CategoryId,
|
|
TargetContentNodeId = command.ContentNodeId,
|
|
TargetQuestionBankId = command.QuestionBankId,
|
|
ImportType = importType,
|
|
SourceFormat = sourceFormat,
|
|
Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview,
|
|
SourceName = Normalize(command.SourceName),
|
|
DryRun = command.DryRun,
|
|
TotalCount = items.Length,
|
|
ValidCount = items.Length,
|
|
RawPayload = JsonSerializer.SerializeToElement(items),
|
|
NormalizedPayload = JsonSerializer.SerializeToElement(items),
|
|
StartedAt = execute ? DateTimeOffset.UtcNow : null,
|
|
FinishedAt = execute ? DateTimeOffset.UtcNow : null
|
|
};
|
|
dbContext.ContentImportJobs.Add(job);
|
|
|
|
var importItems = new List<ContentImportItem>();
|
|
var rowNo = 1;
|
|
foreach (var payload in items)
|
|
{
|
|
var importItem = new ContentImportItem
|
|
{
|
|
TenantId = actor.TenantId,
|
|
JobId = job.Id,
|
|
RowNo = rowNo++,
|
|
ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"),
|
|
Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid,
|
|
SourcePayload = payload,
|
|
NormalizedPayload = payload
|
|
};
|
|
|
|
if (execute)
|
|
{
|
|
var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken);
|
|
importItem.TargetType = target.TargetType;
|
|
importItem.TargetId = target.TargetId;
|
|
job.InsertedCount++;
|
|
}
|
|
|
|
importItems.Add(importItem);
|
|
}
|
|
|
|
dbContext.ContentImportItems.AddRange(importItems);
|
|
job.Summary = JsonSerializer.SerializeToElement(new
|
|
{
|
|
mode = execute ? "execute" : "preview",
|
|
supportedTypes = SupportedImportTypes,
|
|
note = "Synchronous direct migration import skeleton; async worker will be introduced later."
|
|
});
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return new SimpleImportResult(
|
|
ToJobItem(job),
|
|
importItems.Select(ToImportItem).ToArray(),
|
|
[]);
|
|
}
|
|
|
|
private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync(
|
|
DirectContentActor actor,
|
|
SimpleImportCommand command,
|
|
JsonElement payload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
switch (command.ImportType.ToLowerInvariant())
|
|
{
|
|
case "questions":
|
|
var result = await CreateQuestionAsync(actor, new QuestionWriteCommand(
|
|
null,
|
|
command.QuestionBankId,
|
|
command.SubjectId,
|
|
command.CategoryId,
|
|
null,
|
|
command.EntryId,
|
|
command.ContentNodeId,
|
|
command.CollectionId,
|
|
GetString(payload, "legacyId"),
|
|
GetString(payload, "type") ?? "choice",
|
|
GetString(payload, "typeLabel"),
|
|
GetInt(payload, "difficulty"),
|
|
GetElement(payload, "tags", JsonDefaults.Array()),
|
|
GetString(payload, "content") ?? GetString(payload, "title"),
|
|
GetElement(payload, "options", JsonDefaults.Array()),
|
|
GetInt(payload, "correctOptionIndex"),
|
|
GetElement(payload, "correctOptionIndices", JsonDefaults.Array()),
|
|
GetString(payload, "answerText") ?? GetString(payload, "answer"),
|
|
GetString(payload, "explanation"),
|
|
GetElement(payload, "subQuestions", JsonDefaults.Array()),
|
|
GetString(payload, "codeLang"),
|
|
GetString(payload, "codeTemplate"),
|
|
GetString(payload, "mediaUrl"),
|
|
"Published",
|
|
GetElement(payload, "examMarkers", JsonDefaults.Object()),
|
|
GetString(payload, "sourceHash"),
|
|
true), cancellationToken);
|
|
return ("question", result.Item.Id);
|
|
case "vocabulary":
|
|
var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand(
|
|
null,
|
|
null,
|
|
command.EntryId,
|
|
command.ContentNodeId,
|
|
GetString(payload, "legacyId"),
|
|
GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词",
|
|
GetString(payload, "phonetic"),
|
|
GetString(payload, "meaning"),
|
|
GetString(payload, "example"),
|
|
GetString(payload, "exampleTranslation"),
|
|
GetInt(payload, "difficulty"),
|
|
GetElement(payload, "tags", JsonDefaults.Array()),
|
|
GetInt(payload, "order"),
|
|
true,
|
|
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
|
return ("vocabulary_word", word.Item.Id);
|
|
case "handbook":
|
|
var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand(
|
|
null,
|
|
null,
|
|
command.EntryId,
|
|
command.ContentNodeId,
|
|
GetString(payload, "legacyId"),
|
|
GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目",
|
|
GetString(payload, "summary"),
|
|
GetString(payload, "content"),
|
|
GetElement(payload, "tags", JsonDefaults.Array()),
|
|
GetInt(payload, "order"),
|
|
true,
|
|
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
|
return ("handbook_entry", entry.Item.Id);
|
|
case "scoreline":
|
|
var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand(
|
|
null,
|
|
command.RegionId,
|
|
GetGuid(payload, "schoolId"),
|
|
GetGuid(payload, "majorId"),
|
|
GetString(payload, "legacyId"),
|
|
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,
|
|
command.SubjectId,
|
|
GetString(payload, "legacyId"),
|
|
GetString(payload, "title") ?? "未命名视频",
|
|
GetString(payload, "description"),
|
|
GetString(payload, "videoUrl") ?? GetString(payload, "url"),
|
|
GetString(payload, "thumbnailUrl"),
|
|
GetInt(payload, "durationSeconds"),
|
|
GetElement(payload, "knowledgeTags", JsonDefaults.Array()),
|
|
GetBool(payload, "isGeneral"),
|
|
GetInt(payload, "difficulty"),
|
|
GetInt(payload, "order"),
|
|
true,
|
|
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
|
return ("video_explanation", video.Item.Id);
|
|
default:
|
|
throw new ContentManagementException("Import type is invalid.", "import_type_invalid");
|
|
}
|
|
}
|
|
|
|
private async Task<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId,
|
|
cancellationToken);
|
|
var isNew = item is null;
|
|
item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
|
item.RegionId = command.RegionId;
|
|
item.LegacyId = Normalize(command.LegacyId);
|
|
item.Title = Normalize(command.Title);
|
|
item.Subtitle = Normalize(command.Subtitle);
|
|
item.Content = Normalize(command.Content);
|
|
item.ButtonText = Normalize(command.ButtonText);
|
|
item.ButtonLink = Normalize(command.ButtonLink);
|
|
item.BackgroundColor = Normalize(command.BackgroundColor);
|
|
item.BorderColor = Normalize(command.BorderColor);
|
|
item.SortOrder = command.Order ?? item.SortOrder;
|
|
item.IsActive = command.IsActive ?? item.IsActive;
|
|
if (isNew) dbContext.Banners.Add(item);
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return item;
|
|
}
|
|
|
|
private async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId,
|
|
cancellationToken);
|
|
var isNew = item is null;
|
|
item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
|
item.RegionId = command.RegionId;
|
|
item.LegacyId = Normalize(command.LegacyId);
|
|
item.Question = Normalize(command.Question) ?? Normalize(command.Title);
|
|
item.Answer = Normalize(command.Answer) ?? Normalize(command.Content);
|
|
item.SortOrder = command.Order ?? item.SortOrder;
|
|
item.IsActive = command.IsActive ?? item.IsActive;
|
|
if (isNew) dbContext.Faqs.Add(item);
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return item;
|
|
}
|
|
|
|
private async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId,
|
|
cancellationToken);
|
|
var isNew = item is null;
|
|
item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
|
item.LegacyId = Normalize(command.LegacyId);
|
|
item.Content = Normalize(command.Content) ?? Normalize(command.Title);
|
|
item.Link = Normalize(command.Link);
|
|
item.BackgroundColor = Normalize(command.BackgroundColor);
|
|
item.SortOrder = command.Order ?? item.SortOrder;
|
|
item.IsActive = command.IsActive ?? item.IsActive;
|
|
if (isNew) dbContext.Announcements.Add(item);
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return item;
|
|
}
|
|
|
|
private async Task<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName);
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
|
var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId,
|
|
cancellationToken);
|
|
var isNew = item is null;
|
|
item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
|
item.RegionId = command.RegionId;
|
|
item.SchoolId = command.SchoolId;
|
|
item.LegacyId = Normalize(command.LegacyId);
|
|
item.ExamName = command.ExamName.Trim();
|
|
item.ExamAt = command.ExamAt;
|
|
item.ExamType = Normalize(command.ExamType);
|
|
item.Description = Normalize(command.Description) ?? Normalize(command.Content);
|
|
item.SortOrder = command.Order ?? item.SortOrder;
|
|
item.IsActive = command.IsActive ?? item.IsActive;
|
|
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
|
if (isNew) dbContext.ExamDates.Add(item);
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return item;
|
|
}
|
|
|
|
private static void ApplyQuestion(Question question, QuestionWriteCommand command)
|
|
{
|
|
question.QuestionBankId = command.QuestionBankId;
|
|
question.SubjectId = command.SubjectId;
|
|
question.CategoryId = command.CategoryId;
|
|
question.NodeId = command.NodeId;
|
|
question.EntryId = command.EntryId;
|
|
question.ContentNodeId = command.ContentNodeId;
|
|
question.PrimaryCollectionId = command.PrimaryCollectionId;
|
|
question.LegacyId = Normalize(command.LegacyId);
|
|
question.Type = Normalize(command.Type) ?? question.Type;
|
|
question.TypeLabel = Normalize(command.TypeLabel);
|
|
question.Difficulty = command.Difficulty;
|
|
question.Tags = JsonArrayOrDefault(command.Tags);
|
|
question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers);
|
|
question.MediaUrl = Normalize(command.MediaUrl);
|
|
question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
|
}
|
|
|
|
private static void ValidateQuestionForPublication(QuestionWriteCommand command)
|
|
{
|
|
var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
|
if (status != QuestionStatus.Published) return;
|
|
|
|
var type = Normalize(command.Type) ?? "choice";
|
|
if (!QuestionGrader.HasValidAuthoritativeAnswer(
|
|
type,
|
|
command.CorrectOptionIndex,
|
|
command.CorrectOptionIndices,
|
|
command.AnswerText))
|
|
throw new ContentManagementException(
|
|
"Published questions require a valid authoritative answer.",
|
|
"question_grading_rule_invalid");
|
|
}
|
|
|
|
private static QuestionVersion BuildQuestionVersion(
|
|
DirectContentActor actor,
|
|
Guid questionId,
|
|
int versionNo,
|
|
QuestionWriteCommand command)
|
|
{
|
|
var version = new QuestionVersion
|
|
{
|
|
TenantId = actor.TenantId,
|
|
QuestionId = questionId,
|
|
VersionNo = versionNo,
|
|
CreatedBy = actor.UserId
|
|
};
|
|
ApplyQuestionVersion(version, command);
|
|
return version;
|
|
}
|
|
|
|
private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command)
|
|
{
|
|
version.Content = Normalize(command.Content);
|
|
version.Options = JsonArrayOrDefault(command.Options);
|
|
version.CorrectOptionIndex = command.CorrectOptionIndex;
|
|
version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices);
|
|
version.AnswerText = Normalize(command.AnswerText);
|
|
version.Explanation = Normalize(command.Explanation);
|
|
version.SubQuestions = JsonArrayOrDefault(command.SubQuestions);
|
|
version.CodeLang = Normalize(command.CodeLang);
|
|
version.CodeTemplate = Normalize(command.CodeTemplate);
|
|
version.SourceHash = Normalize(command.SourceHash);
|
|
}
|
|
|
|
private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await AssertReferenceAsync<QuestionBank>(tenantId, command.QuestionBankId, "question_bank_not_found",
|
|
cancellationToken);
|
|
await AssertReferenceAsync<Subject>(tenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
|
await AssertReferenceAsync<Category>(tenantId, command.CategoryId, "category_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ModuleNode>(tenantId, command.NodeId, "module_node_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(tenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(tenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
|
await AssertReferenceAsync<QuestionCollection>(tenantId, command.PrimaryCollectionId, "collection_not_found",
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task SyncPrimaryCollectionItemAsync(
|
|
DirectContentActor actor,
|
|
Question question,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!question.PrimaryCollectionId.HasValue) return;
|
|
|
|
var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync(
|
|
item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.CollectionId == question.PrimaryCollectionId.Value &&
|
|
item.QuestionId == question.Id,
|
|
cancellationToken);
|
|
if (existing is null)
|
|
{
|
|
var reference = await questionReferenceService.ResolveAsync(
|
|
actor.TenantId,
|
|
actor.UserId,
|
|
new QuestionLocator(QuestionSource.Tenant, question.Id),
|
|
cancellationToken);
|
|
var nextOrder = await dbContext.QuestionCollectionItems
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
|
|
.Select(item => (int?)item.SortOrder)
|
|
.MaxAsync(cancellationToken) ?? -1;
|
|
dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem
|
|
{
|
|
TenantId = actor.TenantId,
|
|
CollectionId = question.PrimaryCollectionId.Value,
|
|
QuestionReferenceId = reference.Id,
|
|
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
|
QuestionId = question.Id,
|
|
SortOrder = nextOrder + 1
|
|
});
|
|
}
|
|
|
|
var collection = await dbContext.QuestionCollections.SingleAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value,
|
|
cancellationToken);
|
|
collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value,
|
|
cancellationToken) + (existing is null ? 1 : 0);
|
|
collection.UpdatedBy = actor.UserId;
|
|
}
|
|
|
|
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync(
|
|
Guid tenantId,
|
|
Guid? unitId,
|
|
Guid? entryId,
|
|
Guid? contentNodeId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!unitId.HasValue) return (entryId, contentNodeId);
|
|
|
|
var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == unitId.Value,
|
|
cancellationToken);
|
|
if (unit is null)
|
|
throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found");
|
|
|
|
return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId);
|
|
}
|
|
|
|
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync(
|
|
Guid tenantId,
|
|
Guid? subjectId,
|
|
Guid? entryId,
|
|
Guid? contentNodeId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!subjectId.HasValue) return (entryId, contentNodeId);
|
|
|
|
var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == subjectId.Value,
|
|
cancellationToken);
|
|
if (subject is null)
|
|
throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found");
|
|
|
|
return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId);
|
|
}
|
|
|
|
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync(
|
|
Guid tenantId,
|
|
Guid? chapterId,
|
|
Guid? entryId,
|
|
Guid? contentNodeId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!chapterId.HasValue) return (entryId, contentNodeId);
|
|
|
|
var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == chapterId.Value,
|
|
cancellationToken);
|
|
if (chapter is null)
|
|
throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found");
|
|
|
|
return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId);
|
|
}
|
|
} |