336 lines
17 KiB
C#
336 lines
17 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
|
|
{
|
|
protected Task<PlatformQuestionImportResult> PreviewImportCoreAsync(PlatformAdminActor actor,
|
|
PlatformQuestionImportCommand command, CancellationToken cancellationToken = default)
|
|
{
|
|
return ImportAsync(actor, command, false, cancellationToken);
|
|
}
|
|
|
|
protected Task<PlatformQuestionImportResult> ExecuteImportCoreAsync(PlatformAdminActor actor,
|
|
PlatformQuestionImportCommand command, CancellationToken cancellationToken = default)
|
|
{
|
|
return ImportAsync(actor, command, true, cancellationToken);
|
|
}
|
|
|
|
protected Task<ContentImportJobDetail> GetImportCoreAsync(PlatformAdminActor actor, Guid jobId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return ExecuteAsync(actor, "查询公共题库导入结果",
|
|
async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token),
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<PlatformQuestionImportResult> ImportAsync(PlatformAdminActor actor,
|
|
PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken)
|
|
{
|
|
return await ExecuteAsync(actor, execute ? "执行公共题库导入" : "预检公共题库导入", async (_, dbContext, tenantId, token) =>
|
|
{
|
|
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
|
|
var format = NormalizeImportFormat(command.Format);
|
|
ContentNode? targetNode = null;
|
|
if (command.ContentNodeId.HasValue)
|
|
targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token);
|
|
if (format == "simple" && targetNode is null)
|
|
throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required");
|
|
|
|
var job = new ContentImportJob
|
|
{
|
|
TenantId = tenantId,
|
|
CreatedBy = actor.UserId,
|
|
TargetQuestionBankId = bank.Id,
|
|
TargetContentNodeId = targetNode?.Id,
|
|
ImportType = ContentImportType.Questions,
|
|
SourceFormat = ImportSourceFormat.Json,
|
|
Status = execute ? ContentImportStatus.Importing : ContentImportStatus.Preview,
|
|
SourceName = string.IsNullOrWhiteSpace(command.SourceName) ? null : command.SourceName.Trim(),
|
|
SourceHash = Hash(command.Payload.GetRawText()),
|
|
DryRun = !execute,
|
|
RawPayload = command.Payload.Clone(),
|
|
StartedAt = execute ? DateTimeOffset.UtcNow : null
|
|
};
|
|
if (execute) dbContext.ContentImportJobs.Add(job);
|
|
|
|
var rows = format == "simple"
|
|
? ExtractSimpleRows(command.Payload, targetNode!.Id)
|
|
: ExtractStructuredRows(command.Payload, targetNode?.Id);
|
|
job.TotalCount = rows.Count;
|
|
var importItems = new List<ContentImportItem>();
|
|
var issues = new List<ContentImportIssue>();
|
|
var createdNodes = 0;
|
|
var inserted = 0;
|
|
var updated = 0;
|
|
var skipped = 0;
|
|
var nodeCache = new Dictionary<string, ContentNode>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var row in rows)
|
|
{
|
|
var item = new ContentImportItem
|
|
{
|
|
TenantId = tenantId,
|
|
JobId = job.Id,
|
|
RowNo = row.RowNo,
|
|
ExternalId = GetString(row.Question, "legacyId") ?? GetString(row.Question, "id"),
|
|
SourcePayload = row.Question.Clone(),
|
|
NormalizedPayload = row.Question.Clone()
|
|
};
|
|
var validation = ValidateImportRow(row.Question);
|
|
if (validation is not null)
|
|
{
|
|
item.Status = ContentImportItemStatus.Invalid;
|
|
item.IssuesCount = 1;
|
|
issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code,
|
|
validation.Value.Field, validation.Value.Message));
|
|
job.ErrorCount++;
|
|
importItems.Add(item);
|
|
continue;
|
|
}
|
|
|
|
job.ValidCount++;
|
|
item.Status = ContentImportItemStatus.Valid;
|
|
if (execute)
|
|
{
|
|
var node = targetNode;
|
|
if (format == "structured")
|
|
{
|
|
(node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path,
|
|
targetNode, nodeCache, token);
|
|
createdNodes += made;
|
|
}
|
|
|
|
if (node is null) throw Error("导入题目没有可用的目标章节。", "import_target_node_required");
|
|
var result =
|
|
await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token);
|
|
item.TargetType = "question";
|
|
item.TargetId = result.Question.Id;
|
|
item.ContentHash = result.SourceHash;
|
|
item.Status = result.Action switch
|
|
{
|
|
"inserted" => ContentImportItemStatus.Inserted,
|
|
"updated" => ContentImportItemStatus.Updated,
|
|
_ => ContentImportItemStatus.Skipped
|
|
};
|
|
if (result.Action == "inserted") inserted++;
|
|
else if (result.Action == "updated") updated++;
|
|
else skipped++;
|
|
}
|
|
|
|
importItems.Add(item);
|
|
}
|
|
|
|
job.InsertedCount = inserted;
|
|
job.UpdatedCount = updated;
|
|
job.SkippedCount = skipped;
|
|
job.Status = !execute ? ContentImportStatus.Preview :
|
|
job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed;
|
|
job.FinishedAt = execute ? DateTimeOffset.UtcNow : null;
|
|
job.Summary = JsonSerializer.SerializeToElement(new
|
|
{ format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount });
|
|
job.NormalizedPayload = JsonSerializer.SerializeToElement(rows.Select(item => item.Question));
|
|
if (execute)
|
|
{
|
|
dbContext.ContentImportItems.AddRange(importItems);
|
|
dbContext.ContentImportIssues.AddRange(issues);
|
|
AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id,
|
|
new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount });
|
|
await dbContext.SaveChangesAsync(token);
|
|
}
|
|
|
|
var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(),
|
|
issues.Select(ToIssueItem).ToArray());
|
|
return new PlatformQuestionImportResult(detail, createdNodes, inserted, updated, skipped);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(IPlatformQuestionBankAdministrationPersistence dbContext,
|
|
PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection<ImportPathPart> path,
|
|
ContentNode? root, Dictionary<string, ContentNode> cache, CancellationToken token)
|
|
{
|
|
var parent = root;
|
|
var created = 0;
|
|
foreach (var part in path)
|
|
{
|
|
var cacheKey = $"{parent?.Id:N}/{part.Key}";
|
|
if (!cache.TryGetValue(cacheKey, out var node))
|
|
{
|
|
var parentId = parent?.Id;
|
|
node = await dbContext.ContentNodes.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId &&
|
|
item.ParentId == parentId && item.NodeKey == part.Key, token);
|
|
if (node is null)
|
|
{
|
|
node = new ContentNode
|
|
{
|
|
TenantId = tenantId,
|
|
EntryId = bank.ContentEntryId!.Value,
|
|
ParentId = parent?.Id,
|
|
NodeKey = part.Key,
|
|
Name = part.Name,
|
|
NodeType = part.Type,
|
|
Depth = parent is null ? 0 : parent.Depth + 1,
|
|
Path = parent is null ? null : $"{parent.Path}.n{Guid.NewGuid():N}",
|
|
SortOrder = part.Order,
|
|
IsActive = true,
|
|
IsSelectable = true,
|
|
IsLeaf = part.Type is ContentNodeType.Chapter or ContentNodeType.Paper,
|
|
CreatedBy = actor.UserId,
|
|
UpdatedBy = actor.UserId
|
|
};
|
|
node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}";
|
|
dbContext.ContentNodes.Add(node);
|
|
await dbContext.SaveChangesAsync(token);
|
|
created++;
|
|
}
|
|
|
|
cache[cacheKey] = node;
|
|
}
|
|
|
|
parent = node;
|
|
}
|
|
|
|
if (parent is null) throw Error("结构化导入未包含可用的章节或试卷。", "structured_import_path_required");
|
|
return (parent, created);
|
|
}
|
|
|
|
private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync(
|
|
IPlatformQuestionBankAdministrationPersistence dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node,
|
|
JsonElement payload, CancellationToken token)
|
|
{
|
|
var legacyId = GetString(payload, "legacyId") ?? GetString(payload, "id");
|
|
var sourceHash = GetString(payload, "sourceHash") ?? Hash(payload.GetRawText());
|
|
var question = !string.IsNullOrWhiteSpace(legacyId)
|
|
? await dbContext.Questions.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.LegacyId == legacyId, token)
|
|
: await dbContext.Questions.Where(item => item.TenantId == tenantId && item.QuestionBankId == bank.Id)
|
|
.Join(
|
|
dbContext.QuestionVersions.Where(item =>
|
|
item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId,
|
|
version => version.Id, (item, _) => item)
|
|
.SingleOrDefaultAsync(token);
|
|
if (question is not null && question.QuestionBankId != bank.Id)
|
|
throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict");
|
|
if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync(
|
|
item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token))
|
|
return (question, "skipped", sourceHash);
|
|
var isNew = question is null;
|
|
question ??= new Question
|
|
{ TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId };
|
|
if (isNew) dbContext.Questions.Add(question);
|
|
var command = FromImportPayload(bank.Id, node.Id, payload, legacyId, sourceHash);
|
|
ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id);
|
|
await dbContext.SaveChangesAsync(token);
|
|
var nextVersion = await dbContext.QuestionVersions
|
|
.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id)
|
|
.Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0;
|
|
var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command);
|
|
dbContext.QuestionVersions.Add(version);
|
|
question.CurrentVersionId = version.Id;
|
|
await dbContext.SaveChangesAsync(token);
|
|
return (question, isNew ? "inserted" : "updated", sourceHash);
|
|
}
|
|
|
|
private static List<ImportRow> ExtractSimpleRows(JsonElement payload, Guid nodeId)
|
|
{
|
|
var array = payload.ValueKind == JsonValueKind.Array
|
|
? payload
|
|
:
|
|
payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions)
|
|
?
|
|
questions
|
|
: default;
|
|
if (array.ValueKind != JsonValueKind.Array)
|
|
throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid");
|
|
return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId))
|
|
.ToList();
|
|
}
|
|
|
|
private static List<ImportRow> ExtractStructuredRows(JsonElement payload, Guid? rootNodeId)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object)
|
|
throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid");
|
|
if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0")
|
|
throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid");
|
|
var rows = new List<ImportRow>();
|
|
WalkStructured(payload, [], rows, rootNodeId);
|
|
if (rows.Count == 0) throw Error("结构化文件中没有找到题目。", "structured_import_questions_empty");
|
|
return rows;
|
|
}
|
|
|
|
private static void WalkStructured(JsonElement current, List<ImportPathPart> path, List<ImportRow> rows,
|
|
Guid? rootNodeId)
|
|
{
|
|
if (current.ValueKind != JsonValueKind.Object) return;
|
|
if (current.TryGetProperty("questions", out var questions) && questions.ValueKind == JsonValueKind.Array)
|
|
foreach (var question in questions.EnumerateArray())
|
|
rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId));
|
|
string[] childProperties = ["categories", "children", "subjects", "chapters", "papers", "nodes"];
|
|
foreach (var property in childProperties)
|
|
{
|
|
if (!current.TryGetProperty(property, out var children) ||
|
|
children.ValueKind != JsonValueKind.Array) continue;
|
|
foreach (var child in children.EnumerateArray())
|
|
{
|
|
if (child.ValueKind != JsonValueKind.Object) continue;
|
|
var name = GetString(child, "name") ?? GetString(child, "title") ?? "未命名节点";
|
|
var key = GetString(child, "code") ?? GetString(child, "key") ??
|
|
GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}";
|
|
var type = property switch
|
|
{
|
|
"subjects" => ContentNodeType.Subject,
|
|
"chapters" => ContentNodeType.Chapter,
|
|
"papers" => ContentNodeType.Paper,
|
|
_ => ParseNodeType(GetString(child, "type"), ContentNodeType.Category)
|
|
};
|
|
var next = new List<ImportPathPart>(path)
|
|
{ new(key, name, type, GetInt(child, "order") ?? path.Count) };
|
|
WalkStructured(child, next, rows, rootNodeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static (string Code, string Field, string Message)? ValidateImportRow(JsonElement payload)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object) return ("question_payload_invalid", "$", "题目必须是 JSON 对象。");
|
|
if (string.IsNullOrWhiteSpace(GetString(payload, "content")) &&
|
|
string.IsNullOrWhiteSpace(GetString(payload, "title")))
|
|
return ("question_content_required", "content", "题干不能为空。");
|
|
var type = GetString(payload, "type") ?? "choice";
|
|
if (!SupportedQuestionTypes.Contains(type)) return ("question_type_invalid", "type", $"不支持的题型:{type}。");
|
|
return null;
|
|
}
|
|
|
|
private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload,
|
|
string? legacyId, string sourceHash)
|
|
{
|
|
return new UpsertPlatformQuestionCommand(
|
|
null, bankId, nodeId, 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"), GetString(payload, "status") ?? "published",
|
|
GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash);
|
|
}
|
|
|
|
}
|