Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

1033 lines
53 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 sealed class PlatformQuestionBankService(
ICurrentAccessContext currentAccessContext,
ITenantExecutionScope tenantExecutionScope) : IPlatformQuestionBankService
{
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"
};
public Task<IReadOnlyCollection<PlatformQuestionBankItem>> GetBanksAsync(
PlatformAdminActor actor,
PlatformQuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformQuestionBankItem>>(actor, "查询平台公共题库",
async (_, dbContext, tenantId, token) =>
{
var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item => item.Name.Contains(keyword));
}
if (!string.IsNullOrWhiteSpace(filter.Status) &&
!filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase))
{
var status = ParseBankStatus(filter.Status);
query = query.Where(item => item.Status == status);
}
var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token);
var entryIds = banks.Where(item => item.ContentEntryId.HasValue)
.Select(item => item.ContentEntryId!.Value).ToArray();
var nodeCounts = await dbContext.ContentNodes.AsNoTracking()
.Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive)
.GroupBy(item => item.EntryId)
.Select(group => new { EntryId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.EntryId, item => item.Count, token);
var questionCounts = await dbContext.Questions.AsNoTracking()
.Where(item =>
item.TenantId == tenantId && item.QuestionBankId.HasValue &&
item.Status != QuestionStatus.Archived)
.GroupBy(item => item.QuestionBankId!.Value)
.Select(group => new { BankId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.BankId, item => item.Count, token);
return banks.Select(item => ToBankItem(
item,
item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes)
? nodes
: 0,
questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray();
}, cancellationToken);
}
public Task<PlatformQuestionBankItem> UpsertBankAsync(
PlatformAdminActor actor,
UpsertPlatformQuestionBankCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) =>
{
if (string.IsNullOrWhiteSpace(command.Name)) throw Error("题库名称不能为空。", "question_bank_name_required");
var bank = command.Id.HasValue
? await dbContext.QuestionBanks.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.Id.Value, token)
: null;
if (command.Id.HasValue && bank is null) throw Error("公共题库不存在。", "question_bank_not_found");
ContentEntry? trackedEntry = null;
if (bank is null)
{
var entry = new ContentEntry
{
TenantId = tenantId,
EntryKey = $"public-question-bank-{Guid.NewGuid():N}",
Name = command.Name.Trim(),
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Public,
IsActive = true,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
trackedEntry = entry;
bank = new QuestionBank
{
TenantId = tenantId,
ContentEntryId = entry.Id,
Status = QuestionBankStatus.Active
};
dbContext.ContentEntries.Add(entry);
dbContext.QuestionBanks.Add(bank);
}
else if (!bank.ContentEntryId.HasValue)
{
var entry = new ContentEntry
{
TenantId = tenantId,
EntryKey = $"public-question-bank-{bank.Id:N}",
Name = command.Name.Trim(),
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Public,
IsActive = true,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId
};
trackedEntry = entry;
dbContext.ContentEntries.Add(entry);
bank.ContentEntryId = entry.Id;
}
bank.Name = command.Name.Trim();
bank.Metadata = ObjectOrDefault(command.Metadata);
if (bank.ContentEntryId.HasValue)
{
var entry = trackedEntry ??
await dbContext.ContentEntries.SingleAsync(
item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token);
entry.Name = bank.Name;
entry.UpdatedBy = actor.UserId;
}
AddAudit(dbContext, actor, "platform.question_bank.saved", bank.Id, new { bank.Name });
await dbContext.SaveChangesAsync(token);
return ToBankItem(bank, 0, 0);
}, cancellationToken);
}
public Task<PlatformQuestionBankItem> ArchiveBankAsync(
PlatformAdminActor actor,
Guid bankId,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankAsync(dbContext, tenantId, bankId, token);
if (await dbContext.Questions.AnyAsync(
item => item.TenantId == tenantId && item.QuestionBankId == bankId &&
item.Status != QuestionStatus.Archived, token))
throw Error("题库中仍有未归档题目,不能归档题库。", "question_bank_not_empty");
if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync(
item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token))
throw Error("题库中仍有启用的内容层级,不能归档题库。", "question_bank_nodes_not_archived");
bank.Status = QuestionBankStatus.Archived;
AddAudit(dbContext, actor, "platform.question_bank.archived", bank.Id, new { bank.Name });
await dbContext.SaveChangesAsync(token);
return ToBankItem(bank, 0, 0);
}, cancellationToken);
}
public Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> GetNodesAsync(
PlatformAdminActor actor,
Guid bankId,
CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformQuestionBankNodeItem>>(actor, "查询公共题库内容结构",
async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token);
var nodes = await dbContext.ContentNodes.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId)
.OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name)
.ToArrayAsync(token);
var counts = await dbContext.Questions.AsNoTracking()
.Where(item =>
item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue &&
item.Status != QuestionStatus.Archived)
.GroupBy(item => item.ContentNodeId!.Value)
.Select(group => new { NodeId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.NodeId, item => item.Count, token);
return nodes.Select(item =>
ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray();
}, cancellationToken);
}
public Task<PlatformQuestionBankNodeItem> UpsertNodeAsync(
PlatformAdminActor actor,
UpsertPlatformQuestionBankNodeCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
return await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, command, token);
}, cancellationToken);
}
public Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> BatchCreateNodesAsync(
PlatformAdminActor actor,
BatchCreatePlatformQuestionBankNodesCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformQuestionBankNodeItem>>(actor, "批量创建章节或试卷",
async (_, dbContext, tenantId, token) =>
{
if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper)
throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid");
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (names.Length is 0 or > 100) throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid");
var result = new List<PlatformQuestionBankNodeItem>();
var order = 0;
foreach (var name in names)
result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank,
new UpsertPlatformQuestionBankNodeCommand(
null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true,
JsonDefaults.Object()), token));
return result;
}, cancellationToken);
}
public Task<PlatformQuestionBankNodeItem> ArchiveNodeAsync(
PlatformAdminActor actor,
Guid nodeId,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) =>
{
var node = await dbContext.ContentNodes.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == nodeId, token)
?? throw Error("内容节点不存在。", "question_bank_node_not_found");
if (await dbContext.ContentNodes.AnyAsync(
item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token))
throw Error("该节点仍有启用的下级节点,不能归档。", "question_bank_node_has_children");
if (await dbContext.Questions.AnyAsync(
item => item.TenantId == tenantId && item.ContentNodeId == nodeId &&
item.Status != QuestionStatus.Archived, token))
throw Error("该节点仍有未归档题目,不能归档。", "question_bank_node_has_questions");
node.IsActive = false;
node.UpdatedBy = actor.UserId;
var bankId = await dbContext.QuestionBanks
.Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id)
.SingleAsync(token);
AddAudit(dbContext, actor, "platform.question_bank.node_archived", node.Id, new { node.Name });
await dbContext.SaveChangesAsync(token);
return ToNodeItem(node, bankId, 0);
}, cancellationToken);
}
public Task<PlatformQuestionPage> GetQuestionsAsync(
PlatformAdminActor actor,
PlatformQuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) =>
{
if (!filter.QuestionBankId.HasValue) throw Error("请选择公共题库。", "question_bank_id_required");
await RequireBankAsync(dbContext, tenantId, filter.QuestionBankId.Value, token);
var query = dbContext.Questions.AsNoTracking().Where(item =>
item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId);
if (filter.ContentNodeId.HasValue) query = query.Where(item => item.ContentNodeId == filter.ContentNodeId);
if (!string.IsNullOrWhiteSpace(filter.Type))
{
var type = filter.Type.Trim();
query = query.Where(item => item.Type == type);
}
if (filter.Difficulty.HasValue) query = query.Where(item => item.Difficulty == filter.Difficulty);
if (!string.IsNullOrWhiteSpace(filter.Status) &&
!filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase))
{
var status = ParseQuestionStatus(filter.Status);
query = query.Where(item => item.Status == status);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version =>
version.TenantId == tenantId && version.QuestionId == item.Id &&
version.Id == item.CurrentVersionId && version.Content != null &&
version.Content.Contains(keyword)));
}
var page = Math.Max(1, filter.Page);
var pageSize = Math.Clamp(filter.PageSize, 1, MaxPageSize);
var total = await query.CountAsync(token);
var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize)
.Take(pageSize).ToArrayAsync(token);
var versionIds = questions.Where(item => item.CurrentVersionId.HasValue)
.Select(item => item.CurrentVersionId!.Value).ToArray();
var versions = await dbContext.QuestionVersions.AsNoTracking()
.Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, token);
return new PlatformQuestionPage(
questions.Select(item => ToQuestionItem(item,
item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version)
? version
: null)).ToArray(), total, page, pageSize);
}, cancellationToken);
}
public Task<PlatformQuestionItem> UpsertQuestionAsync(
PlatformAdminActor actor,
UpsertPlatformQuestionCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) =>
{
var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token);
var node = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId, token);
ValidateQuestion(command);
var question = command.Id.HasValue
? await dbContext.Questions.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id,
token)
: null;
if (command.Id.HasValue && question is null) throw Error("题目不存在。", "question_not_found");
question ??= new Question
{
TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId,
CreatedAt = DateTimeOffset.UtcNow
};
if (!command.Id.HasValue) dbContext.Questions.Add(question);
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;
AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id,
new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo });
await dbContext.SaveChangesAsync(token);
return ToQuestionItem(question, version);
}, cancellationToken);
}
public Task<int> ArchiveQuestionsAsync(
PlatformAdminActor actor,
ArchivePlatformQuestionsCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) =>
{
var ids = command.QuestionIds.Distinct().Take(500).ToArray();
if (ids.Length == 0) throw Error("请选择需要归档的题目。", "question_ids_required");
var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id))
.ToArrayAsync(token);
foreach (var question in questions) question.Status = QuestionStatus.Archived;
AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(),
new { questionIds = questions.Select(item => item.Id).ToArray() });
await dbContext.SaveChangesAsync(token);
return questions.Length;
}, cancellationToken);
}
public Task<PlatformQuestionImportResult> PreviewImportAsync(PlatformAdminActor actor,
PlatformQuestionImportCommand command, CancellationToken cancellationToken = default)
{
return ImportAsync(actor, command, false, cancellationToken);
}
public Task<PlatformQuestionImportResult> ExecuteImportAsync(PlatformAdminActor actor,
PlatformQuestionImportCommand command, CancellationToken cancellationToken = default)
{
return ImportAsync(actor, command, true, cancellationToken);
}
public Task<ContentImportJobDetail> GetImportAsync(PlatformAdminActor actor, Guid jobId,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "查询公共题库导入结果",
async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token),
cancellationToken);
}
public Task<AssetUploadSignResult> SignQuestionAssetUploadAsync(PlatformAdminActor actor,
AssetUploadSignCommand command, CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) =>
await provider.GetRequiredService<IAssetManagementService>().SignUploadAsync(
new AssetManagementActor(tenantId, actor.UserId),
command with { AssetType = "image", Category = "question", IsPublic = true }, token),
cancellationToken);
}
public Task<AssetUploadConfirmResult> ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor,
AssetUploadConfirmCommand command, CancellationToken cancellationToken = default)
{
return ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) =>
await provider.GetRequiredService<IAssetManagementService>()
.ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, 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 async Task<TResult> ExecuteAsync<TResult>(PlatformAdminActor actor, string reason,
Func<IServiceProvider, TikuDbContext, 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, nameof(PlatformQuestionBankService),
"解析平台公共题库所属租户", correlationId, true),
async (provider, token) =>
{
var dbContext = provider.GetRequiredService<TikuDbContext>();
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,
nameof(PlatformQuestionBankService), reason, correlationId),
async (provider, token) =>
{
var dbContext = provider.GetRequiredService<TikuDbContext>();
return await action(provider, dbContext, platformTenantId, token);
}, cancellationToken);
}
private static async Task<PlatformQuestionBankNodeItem> UpsertNodeCoreAsync(TikuDbContext dbContext,
PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(command.Name)) throw Error("节点名称不能为空。", "question_bank_node_name_required");
ContentNode? parent = null;
if (command.ParentId.HasValue)
parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token);
var node = command.Id.HasValue
? await dbContext.ContentNodes.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId,
token)
: null;
if (command.Id.HasValue && node is null) throw Error("内容节点不存在。", "question_bank_node_not_found");
node ??= new ContentNode
{ TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId };
if (!command.Id.HasValue) dbContext.ContentNodes.Add(node);
node.ParentId = parent?.Id;
node.NodeKey = string.IsNullOrWhiteSpace(command.NodeKey) ? $"node-{node.Id:N}" : command.NodeKey.Trim();
node.Name = command.Name.Trim();
node.NodeType = command.NodeType;
node.Depth = parent is null ? 0 : parent.Depth + 1;
node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}";
node.SortOrder = command.SortOrder;
node.IsActive = true;
node.IsSelectable = command.IsSelectable;
node.IsLeaf = command.NodeType is ContentNodeType.Chapter or ContentNodeType.Paper;
node.Metadata = ObjectOrDefault(command.Metadata);
node.UpdatedBy = actor.UserId;
AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id,
new { bankId = bank.Id, node.Name, node.NodeType });
await dbContext.SaveChangesAsync(token);
return ToNodeItem(node, bank.Id, 0);
}
private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(TikuDbContext 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(
TikuDbContext 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 void ValidateQuestion(UpsertPlatformQuestionCommand command)
{
if (string.IsNullOrWhiteSpace(command.Content)) throw Error("题干不能为空。", "question_content_required");
var type = string.IsNullOrWhiteSpace(command.Type) ? "choice" : command.Type.Trim();
if (!SupportedQuestionTypes.Contains(type)) throw Error("题型不受支持。", "question_type_invalid");
if (command.Difficulty is < 1 or > 5) throw Error("难度必须在 1 到 5 之间。", "question_difficulty_invalid");
if (ParseQuestionStatus(command.Status) == QuestionStatus.Published &&
!QuestionGrader.HasValidAuthoritativeAnswer(
type,
command.CorrectOptionIndex,
command.CorrectOptionIndices,
command.AnswerText))
throw Error("发布题目必须提供有效的标准答案。", "question_grading_rule_invalid");
}
private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId,
Guid nodeId)
{
question.QuestionBankId = command.QuestionBankId;
question.EntryId = entryId;
question.ContentNodeId = nodeId;
question.LegacyId = Normalize(command.LegacyId);
question.Type = Normalize(command.Type) ?? "choice";
question.TypeLabel = Normalize(command.TypeLabel);
question.Difficulty = command.Difficulty;
question.Tags = ArrayOrDefault(command.Tags);
question.MediaUrl = Normalize(command.MediaUrl);
question.ExamMarkers = ObjectOrDefault(command.ExamMarkers);
question.Status = ParseQuestionStatus(command.Status);
}
private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo,
UpsertPlatformQuestionCommand command)
{
return new QuestionVersion
{
TenantId = tenantId,
QuestionId = questionId,
VersionNo = versionNo,
Content = Normalize(command.Content),
Options = ArrayOrDefault(command.Options),
CorrectOptionIndex = command.CorrectOptionIndex,
CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices),
AnswerText = Normalize(command.AnswerText),
Explanation = Normalize(command.Explanation),
SubQuestions = ArrayOrDefault(command.SubQuestions),
CodeLang = Normalize(command.CodeLang),
CodeTemplate = Normalize(command.CodeTemplate),
SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)),
CreatedBy = actor.UserId
};
}
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);
}
private static async Task<QuestionBank> RequireBankAsync(TikuDbContext 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(TikuDbContext 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(TikuDbContext 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(TikuDbContext 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(TikuDbContext 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);
}