Files
tiku-backend.net/Tiku.Infrastructure/Content/Imports/ContentImportService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

340 lines
15 KiB
C#

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<SimpleImportResult> PreviewImportAsync(
DirectContentActor actor,
SimpleImportCommand command,
CancellationToken cancellationToken = default)
{
return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken);
}
public Task<SimpleImportResult> ExecuteImportAsync(
DirectContentActor actor,
SimpleImportCommand command,
CancellationToken cancellationToken = default)
{
return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken);
}
public async Task<ContentImportJobDetail> 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<CatalogList<ContentImportIssueModel>> 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<ContentImportIssueModel>(issues);
}
public async Task<ImportPostCheckResult> 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<ImportPostCheckResult> 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<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
};
questionBankPersistence.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);
}
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");
}
}
}