using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; 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; internal sealed class ContentImportService( DirectContentServiceDependencies dependencies, IQuestionManagementService questionService, IVocabularyManagementService vocabularyService, IHandbookManagementService handbookService, IScorelineManagementService scorelineService, IVideoManagementService videoService) : DirectContentServiceBase(dependencies), IContentImportService { public Task PreviewImportAsync( DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default) { return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken); } public Task ExecuteImportAsync( DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default) { return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken); } public async Task GetImportJobAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { var job = await questionBankPersistence.ContentImportJobs.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) .Select(item => ToJobItem(item)) .SingleOrDefaultAsync(cancellationToken); if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); var items = await questionBankPersistence.ContentImportItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) .OrderBy(item => item.RowNo) .Take(MaxLimit) .Select(item => ToImportItem(item)) .ToArrayAsync(cancellationToken); var issues = await questionBankPersistence.ContentImportIssues.AsNoTracking() .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) .OrderBy(issue => issue.RowNo) .ThenBy(issue => issue.CreatedAt) .Take(MaxLimit) .Select(issue => new ContentImportIssueModel( issue.Id, issue.JobId, issue.ItemId, issue.RowNo, issue.Severity, issue.Code, issue.FieldPath, issue.Message, issue.Details)) .ToArrayAsync(cancellationToken); return new ContentImportJobDetail(job, items, issues); } public async Task> GetImportIssuesAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); var issues = await questionBankPersistence.ContentImportIssues.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) .OrderBy(item => item.RowNo) .ThenBy(item => item.CreatedAt) .Take(MaxLimit) .Select(item => new ContentImportIssueModel( item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, item.FieldPath, item.Message, item.Details)) .ToArrayAsync(cancellationToken); return new CatalogList(issues); } public async Task RunImportPostCheckAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { var job = await questionBankPersistence.ContentImportJobs.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == jobId, cancellationToken); if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); var counts = JsonSerializer.SerializeToElement(new { job.TotalCount, job.ValidCount, job.ErrorCount, job.WarningCount, job.InsertedCount, job.UpdatedCount, job.SkippedCount }); job.Summary = JsonSerializer.SerializeToElement(new { postCheck = new { status = job.ErrorCount == 0 ? "passed" : "warning", checkedAt = DateTimeOffset.UtcNow, counts } }); await unitOfWork.SaveChangesAsync(cancellationToken); return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); } public async Task GetImportPostCheckAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { var job = await questionBankPersistence.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == jobId, cancellationToken); if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); var counts = JsonSerializer.SerializeToElement(new { job.TotalCount, job.ValidCount, job.ErrorCount, job.WarningCount, job.InsertedCount, job.UpdatedCount, job.SkippedCount }); return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); } private async Task 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 }; questionBankPersistence.ContentImportJobs.Add(job); var importItems = new List(); 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); } questionBankPersistence.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 unitOfWork.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 questionService.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 vocabularyService.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 handbookService.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 scorelineService.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 videoService.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"); } } }