using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Assets; using Tiku.Application.Learning; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal abstract partial class PlatformQuestionBankServiceBase( ICurrentAccessContext currentAccessContext, ITenantExecutionScope tenantExecutionScope) { private const int MaxPageSize = 100; private static readonly HashSet SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase) { "choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming" }; private async Task ExecuteAsync(PlatformAdminActor actor, string reason, Func> action, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); if (access.UserId != actor.UserId || !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage)) throw Error("需要平台公共题库管理权限。", "platform_question_bank_access_denied"); var correlationId = Guid.NewGuid().ToString("N"); var platformTenantId = await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(null, SystemScopeCallerType.Platform, "PlatformQuestionBankService", "解析平台公共题库所属租户", correlationId, true), async (provider, token) => { var dbContext = provider.GetRequiredService(); var tenantIds = await dbContext.Tenants.AsNoTracking() .Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2) .ToArrayAsync(token); if (tenantIds.Length != 1) throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", tenantIds.Length == 0 ? "platform_question_owner_missing" : "platform_question_owner_ambiguous"); return tenantIds[0]; }, cancellationToken); return await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, "PlatformQuestionBankService", reason, correlationId), async (provider, token) => { var dbContext = provider.GetRequiredService(); return await action(provider, dbContext, platformTenantId, token); }, cancellationToken); } private static async Task RequireBankAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, Guid bankId, CancellationToken token) { return await dbContext.QuestionBanks.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == bankId, token) ?? throw Error("公共题库不存在。", "question_bank_not_found"); } private static async Task RequireBankWithEntryAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, Guid bankId, CancellationToken token) { var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); if (!bank.ContentEntryId.HasValue) throw Error("题库内容入口尚未初始化,请先编辑并保存题库。", "question_bank_entry_missing"); return bank; } private static async Task RequireNodeAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, QuestionBank bank, Guid nodeId, CancellationToken token) { return await dbContext.ContentNodes.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found"); } private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) { return new PlatformQuestionBankItem(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, questionCount, item.Metadata, item.CreatedAt, item.UpdatedAt); } private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) { return new PlatformQuestionBankNodeItem(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, item.NodeType, item.Depth, item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata); } private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) { return new PlatformQuestionItem(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, item.ContentNodeId!.Value, item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.Tags, version?.Content, version?.Options ?? JsonDefaults.Array(), version?.CorrectOptionIndex, version?.CorrectOptionIndices ?? JsonDefaults.Array(), version?.AnswerText, version?.Explanation, version?.SubQuestions ?? JsonDefaults.Array(), version?.CodeLang, version?.CodeTemplate, item.MediaUrl, item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt); } private static async Task LoadImportDetailAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, Guid jobId, CancellationToken token) { var job = await dbContext.ContentImportJobs.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? throw Error("导入任务不存在。", "question_import_not_found"); var items = await dbContext.ContentImportItems.AsNoTracking() .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) .Select(item => ToImportItem(item)).ToArrayAsync(token); var issues = await dbContext.ContentImportIssues.AsNoTracking() .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) .Select(item => ToIssueItem(item)).ToArrayAsync(token); return new ContentImportJobDetail(ToJobItem(job), items, issues); } private static ContentImportJobItem ToJobItem(ContentImportJob item) { return new ContentImportJobItem(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, item.TargetContentNodeId, item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, item.FinishedAt, item.CreatedAt, item.UpdatedAt); } private static ContentImportItemModel ToImportItem(ContentImportItem item) { return new ContentImportItemModel(item.Id, item.JobId, item.RowNo, item.ExternalId, item.Status, item.TargetType, item.TargetId, item.SourcePayload, item.NormalizedPayload, item.ContentHash, item.IssuesCount); } private static ContentImportIssueModel ToIssueItem(ContentImportIssue item) { return new ContentImportIssueModel(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, item.FieldPath, item.Message, item.Details); } private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, string field, string message) { return new ContentImportIssue { TenantId = tenantId, JobId = jobId, ItemId = itemId, RowNo = rowNo, Severity = ImportIssueSeverity.Error, Code = code, FieldPath = field, Message = message }; } private static void AddAudit(IPlatformQuestionBankAdministrationPersistence dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) { dbContext.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = "question_bank", TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) }); } private static PlatformAdminException Error(string message, string code) { return new PlatformAdminException(message, code); } private static string NormalizeImportFormat(string? value) { return value?.Trim().ToLowerInvariant() switch { "simple" or "json" => "simple", "structured-v2" or "structured" or "v2" => "structured", _ => throw Error("导入格式不受支持。", "question_import_format_invalid") }; } private static QuestionBankStatus ParseBankStatus(string? value) { return value?.Trim().ToLowerInvariant() switch { "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active }; } private static QuestionStatus ParseQuestionStatus(string? value) { return value?.Trim().ToLowerInvariant() switch { "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, _ => QuestionStatus.Published }; } private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) { return value?.Trim().ToLowerInvariant() switch { "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback }; } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static JsonElement ObjectOrDefault(JsonElement value) { return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); } private static JsonElement ArrayOrDefault(JsonElement value) { return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); } private static string Hash(string value) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } private static string? GetString(JsonElement value, string name) { return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; } private static int? GetInt(JsonElement value, string name) { return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : null; } private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) { return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) ? property.Clone() : fallback; } private sealed record ImportPathPart(string Key, string Name, ContentNodeType Type, int Order); private sealed record ImportRow( int RowNo, JsonElement Question, IReadOnlyCollection Path, Guid? TargetNodeId); }