Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportFoundation.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

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