279 lines
12 KiB
C#
279 lines
12 KiB
C#
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<string> SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming"
|
|
};
|
|
|
|
private async Task<TResult> ExecuteAsync<TResult>(PlatformAdminActor actor, string reason,
|
|
Func<IServiceProvider, IPlatformQuestionBankAdministrationPersistence, Guid, CancellationToken, Task<TResult>> 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<IPlatformQuestionBankAdministrationPersistence>();
|
|
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<IPlatformQuestionBankAdministrationPersistence>();
|
|
return await action(provider, dbContext, platformTenantId, token);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
private static async Task<QuestionBank> 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<QuestionBank> 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<ContentNode> 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<ContentImportJobDetail> 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<ImportPathPart> Path,
|
|
Guid? TargetNodeId);
|
|
}
|