refactor(content): split content capability services

This commit is contained in:
2026-08-04 09:23:36 +08:00
parent 1b867cfa3a
commit 4d85463a87
28 changed files with 1160 additions and 929 deletions

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