Files
tiku-backend.net/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

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);
}
}