refactor(content): split content capability services
This commit is contained in:
339
Tiku.Infrastructure/Content/Imports/ContentImportService.cs
Normal file
339
Tiku.Infrastructure/Content/Imports/ContentImportService.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
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 dbContext.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 dbContext.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 dbContext.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 dbContext.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 dbContext.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 dbContext.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 dbContext.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
|
||||
};
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user