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

@@ -11,9 +11,9 @@ using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Content;
public sealed partial class DirectContentService
internal abstract partial class DirectContentServiceBase
{
private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version)
protected static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version)
{
return new QuestionManagementItem(
question.Id,
@@ -44,7 +44,7 @@ public sealed partial class DirectContentService
question.Status);
}
private static VideoManagementItem ToVideoItem(VideoExplanation item)
protected static VideoManagementItem ToVideoItem(VideoExplanation item)
{
return new VideoManagementItem(
item.Id,
@@ -63,7 +63,7 @@ public sealed partial class DirectContentService
item.Metadata);
}
private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item)
protected static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item)
{
return new QuestionVideoManagementItem(
item.Id,
@@ -75,7 +75,7 @@ public sealed partial class DirectContentService
item.Metadata);
}
private static OperationContentItem ToOperationItem(Banner item)
protected static OperationContentItem ToOperationItem(Banner item)
{
return new OperationContentItem(
item.Id,
@@ -101,7 +101,7 @@ public sealed partial class DirectContentService
}));
}
private static OperationContentItem ToOperationItem(Faq item)
protected static OperationContentItem ToOperationItem(Faq item)
{
return new OperationContentItem(
item.Id,
@@ -120,7 +120,7 @@ public sealed partial class DirectContentService
JsonDefaults.Object());
}
private static OperationContentItem ToOperationItem(Announcement item)
protected static OperationContentItem ToOperationItem(Announcement item)
{
return new OperationContentItem(
item.Id,
@@ -143,7 +143,7 @@ public sealed partial class DirectContentService
}));
}
private static OperationContentItem ToOperationItem(ExamDate item)
protected static OperationContentItem ToOperationItem(ExamDate item)
{
return new OperationContentItem(
item.Id,
@@ -162,7 +162,7 @@ public sealed partial class DirectContentService
item.Metadata);
}
private static ContentImportJobItem ToJobItem(ContentImportJob job)
protected static ContentImportJobItem ToJobItem(ContentImportJob job)
{
return new ContentImportJobItem(
job.Id,
@@ -192,7 +192,7 @@ public sealed partial class DirectContentService
job.UpdatedAt);
}
private static ContentImportItemModel ToImportItem(ContentImportItem item)
protected static ContentImportItemModel ToImportItem(ContentImportItem item)
{
return new ContentImportItemModel(
item.Id,
@@ -208,7 +208,7 @@ public sealed partial class DirectContentService
item.IssuesCount);
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
protected async Task<CurrentDataScope> RequireDataScopeAsync(
DirectContentActor actor,
CancellationToken cancellationToken)
{
@@ -222,7 +222,7 @@ public sealed partial class DirectContentService
return access.DataScope;
}
private static void EnsureRegionWriteAllowed(
protected static void EnsureRegionWriteAllowed(
CurrentDataScope scope,
DirectContentActor actor,
Guid? currentRegionId,
@@ -236,7 +236,7 @@ public sealed partial class DirectContentService
throw new ContentManagementException("Content resource was not found.", notFoundCode);
}
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
protected async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
@@ -256,7 +256,7 @@ public sealed partial class DirectContentService
cancellationToken);
}
private async Task AssertReferenceAsync<TEntity>(
protected async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,
string code,
@@ -272,7 +272,7 @@ public sealed partial class DirectContentService
if (!exists) throw new ContentManagementException("Referenced entity was not found.", code);
}
private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken)
protected async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken)
{
var exists = await dbContext.ContentImportJobs.AnyAsync(
item => item.TenantId == tenantId && item.Id == jobId,
@@ -280,7 +280,7 @@ public sealed partial class DirectContentService
if (!exists) throw new ContentManagementException("Import job was not found.", "import_job_not_found");
}
private static string NormalizeOperationKind(string kind)
protected static string NormalizeOperationKind(string kind)
{
var normalized = Normalize(kind)?.ToLowerInvariant();
return normalized switch
@@ -293,7 +293,7 @@ public sealed partial class DirectContentService
};
}
private static ContentImportType ParseImportType(string value)
protected static ContentImportType ParseImportType(string value)
{
return value.ToLowerInvariant() switch
{
@@ -306,7 +306,7 @@ public sealed partial class DirectContentService
};
}
private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
protected static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value)) return fallback;
@@ -316,7 +316,7 @@ public sealed partial class DirectContentService
throw new ContentManagementException("Enum value is invalid.", code);
}
private static TEnum? ParseNullable<TEnum>(string? value, string code)
protected static TEnum? ParseNullable<TEnum>(string? value, string code)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value)) return null;
@@ -326,41 +326,41 @@ public sealed partial class DirectContentService
throw new ContentManagementException("Enum value is invalid.", code);
}
private static string? Normalize(string? value)
protected static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static int ResolveLimit(int? limit)
protected static int ResolveLimit(int? limit)
{
return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit);
}
private static JsonElement JsonObjectOrDefault(JsonElement value)
protected static JsonElement JsonObjectOrDefault(JsonElement value)
{
return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object();
}
private static JsonElement JsonArrayOrDefault(JsonElement value)
protected static JsonElement JsonArrayOrDefault(JsonElement value)
{
return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array();
}
private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback)
protected static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback)
{
return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value)
? value
: fallback;
}
private static string? GetString(JsonElement payload, string name)
protected static string? GetString(JsonElement payload, string name)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString();
}
private static int? GetInt(JsonElement payload, string name)
protected static int? GetInt(JsonElement payload, string name)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
@@ -371,7 +371,7 @@ public sealed partial class DirectContentService
: null;
}
private static Guid? GetGuid(JsonElement payload, string name)
protected static Guid? GetGuid(JsonElement payload, string name)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
@@ -381,7 +381,7 @@ public sealed partial class DirectContentService
: null;
}
private static bool? GetBool(JsonElement payload, string name)
protected static bool? GetBool(JsonElement payload, string name)
{
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null;
@@ -393,4 +393,4 @@ public sealed partial class DirectContentService
_ => null
};
}
}
}

View File

@@ -12,191 +12,9 @@ using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Content;
public sealed partial class DirectContentService
internal abstract partial class DirectContentServiceBase
{
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,
protected async Task<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command,
CancellationToken cancellationToken)
{
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
@@ -221,7 +39,7 @@ public sealed partial class DirectContentService
return item;
}
private async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command,
CancellationToken cancellationToken)
{
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
@@ -241,7 +59,7 @@ public sealed partial class DirectContentService
return item;
}
private async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command,
CancellationToken cancellationToken)
{
var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId,
@@ -260,7 +78,7 @@ public sealed partial class DirectContentService
return item;
}
private async Task<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command,
protected async Task<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName);
@@ -286,7 +104,7 @@ public sealed partial class DirectContentService
return item;
}
private static void ApplyQuestion(Question question, QuestionWriteCommand command)
protected static void ApplyQuestion(Question question, QuestionWriteCommand command)
{
question.QuestionBankId = command.QuestionBankId;
question.SubjectId = command.SubjectId;
@@ -305,7 +123,7 @@ public sealed partial class DirectContentService
question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
}
private static void ValidateQuestionForPublication(QuestionWriteCommand command)
protected static void ValidateQuestionForPublication(QuestionWriteCommand command)
{
var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
if (status != QuestionStatus.Published) return;
@@ -321,7 +139,7 @@ public sealed partial class DirectContentService
"question_grading_rule_invalid");
}
private static QuestionVersion BuildQuestionVersion(
protected static QuestionVersion BuildQuestionVersion(
DirectContentActor actor,
Guid questionId,
int versionNo,
@@ -338,7 +156,7 @@ public sealed partial class DirectContentService
return version;
}
private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command)
protected static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command)
{
version.Content = Normalize(command.Content);
version.Options = JsonArrayOrDefault(command.Options);
@@ -352,7 +170,7 @@ public sealed partial class DirectContentService
version.SourceHash = Normalize(command.SourceHash);
}
private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command,
protected async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command,
CancellationToken cancellationToken)
{
await AssertReferenceAsync<QuestionBank>(tenantId, command.QuestionBankId, "question_bank_not_found",
@@ -366,7 +184,7 @@ public sealed partial class DirectContentService
cancellationToken);
}
private async Task SyncPrimaryCollectionItemAsync(
protected async Task SyncPrimaryCollectionItemAsync(
DirectContentActor actor,
Question question,
CancellationToken cancellationToken)
@@ -411,7 +229,7 @@ public sealed partial class DirectContentService
collection.UpdatedBy = actor.UserId;
}
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync(
protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync(
Guid tenantId,
Guid? unitId,
Guid? entryId,
@@ -429,7 +247,7 @@ public sealed partial class DirectContentService
return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId);
}
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync(
protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync(
Guid tenantId,
Guid? subjectId,
Guid? entryId,
@@ -447,7 +265,7 @@ public sealed partial class DirectContentService
return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId);
}
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync(
protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync(
Guid tenantId,
Guid? chapterId,
Guid? entryId,
@@ -464,4 +282,4 @@ public sealed partial class DirectContentService
return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId);
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.RegularExpressions;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Content;
internal sealed record DirectContentServiceDependencies(
TikuDbContext DbContext,
IQuestionReferenceService QuestionReferenceService,
ICurrentAccessContext CurrentAccessContext,
IFeatureAccessService FeatureAccessService);
internal abstract partial class DirectContentServiceBase(DirectContentServiceDependencies dependencies)
{
protected TikuDbContext dbContext { get; } = dependencies.DbContext;
protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService;
protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext;
protected IFeatureAccessService featureAccessService { get; } = dependencies.FeatureAccessService;
protected const int DefaultLimit = 100;
protected const int MaxLimit = 1000;
protected static readonly string[] ContentPermissions =
[
BackendPermissions.TenantContentManage,
BackendPermissions.TenantVocabularyManage,
BackendPermissions.TenantHandbookManage,
BackendPermissions.TenantVideoManage,
BackendPermissions.TenantScorelineManage,
BackendPermissions.TenantSiteContentManage,
BackendPermissions.TenantJobManage
];
protected static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled);
protected static readonly HashSet<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
{
"questions",
"vocabulary",
"handbook",
"scoreline",
"videos"
};
}