forked from xiongyuxing/tiku-backend.net
feat: add platform public question bank workspace
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -124,6 +125,21 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
|
||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var platformOwnedTenant = await dbContext.Tenants
|
||||
.SingleOrDefaultAsync(item => item.Mode == TenantMode.PlatformOwned, cancellationToken);
|
||||
if (platformOwnedTenant is null)
|
||||
{
|
||||
dbContext.Tenants.Add(new Tenant
|
||||
{
|
||||
Slug = "platform-public-content",
|
||||
Name = "平台公共内容",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.PlatformOwned,
|
||||
BillingStatus = BillingStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
});
|
||||
}
|
||||
|
||||
var featureCodes = Features.Select(item => item.Code).ToArray();
|
||||
var existingFeatureCodes = await dbContext.SaasFeatures.AsNoTracking()
|
||||
.Where(item => featureCodes.Contains(item.Code))
|
||||
|
||||
@@ -133,6 +133,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddScoped<IPlatformQuestionBankService, PlatformQuestionBankService>();
|
||||
services.AddScoped<IPlatformCrmAdminService, PlatformCrmAdminService>();
|
||||
services.AddScoped<IPlatformSmsAdminService, PlatformSmsAdminService>();
|
||||
services.AddScoped<IPlatformPaymentSettingsService, PlatformPaymentSettingsService>();
|
||||
|
||||
@@ -21,6 +21,10 @@ internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<Quest
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<ContentEntry>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ContentEntryId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20087
Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.Designer.cs
generated
Normal file
20087
Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlatformQuestionBankWorkspace : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "content_entry_id",
|
||||
table: "question_banks",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO content_entries (
|
||||
id, tenant_id, entry_key, name, entry_type, visibility,
|
||||
access_rules, layout_config, sort_order, is_active, created_at, updated_at)
|
||||
SELECT
|
||||
qb.id,
|
||||
qb.tenant_id,
|
||||
'public-question-bank-' || replace(qb.id::text, '-', ''),
|
||||
qb.name,
|
||||
'question_practice',
|
||||
'public',
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
0,
|
||||
true,
|
||||
qb.created_at,
|
||||
qb.updated_at
|
||||
FROM question_banks qb
|
||||
JOIN tenants t ON t.id = qb.tenant_id AND t.mode = 'platform_owned'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM content_entries ce WHERE ce.id = qb.id);
|
||||
|
||||
UPDATE question_banks qb
|
||||
SET content_entry_id = qb.id
|
||||
FROM tenants t
|
||||
WHERE t.id = qb.tenant_id
|
||||
AND t.mode = 'platform_owned'
|
||||
AND qb.content_entry_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM content_entries ce WHERE ce.tenant_id = qb.tenant_id AND ce.id = qb.id);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_question_banks_tenant_id_content_entry_id",
|
||||
table: "question_banks",
|
||||
columns: new[] { "tenant_id", "content_entry_id" });
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_question_banks_content_entries_tenant_id_content_entry_id",
|
||||
table: "question_banks",
|
||||
columns: new[] { "tenant_id", "content_entry_id" },
|
||||
principalTable: "content_entries",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_question_banks_content_entries_tenant_id_content_entry_id",
|
||||
table: "question_banks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_question_banks_tenant_id_content_entry_id",
|
||||
table: "question_banks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "content_entry_id",
|
||||
table: "question_banks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14290,6 +14290,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("ContentEntryId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("content_entry_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -14334,6 +14338,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_question_banks_tenant_id_id");
|
||||
|
||||
b.HasIndex("TenantId", "ContentEntryId")
|
||||
.HasDatabaseName("ix_question_banks_tenant_id_content_entry_id");
|
||||
|
||||
b.HasIndex("TenantId", "RegionId")
|
||||
.HasDatabaseName("ix_question_banks_tenant_id_region_id");
|
||||
|
||||
@@ -19706,6 +19713,13 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_question_banks_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Content.ContentEntry", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ContentEntryId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_question_banks_content_entries_tenant_id_content_entry_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Catalog.Region", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "RegionId")
|
||||
|
||||
711
Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs
Normal file
711
Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs
Normal file
@@ -0,0 +1,711 @@
|
||||
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.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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
ImportAsync(actor, command, false, cancellationToken);
|
||||
|
||||
public Task<PlatformQuestionImportResult> ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) =>
|
||||
ImportAsync(actor, command, true, cancellationToken);
|
||||
|
||||
public Task<ContentImportJobDetail> GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync(actor, "查询公共题库导入结果", async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), cancellationToken);
|
||||
|
||||
public Task<AssetUploadSignResult> SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default) =>
|
||||
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) =>
|
||||
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, IsGlobal: 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 => (Guid?)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");
|
||||
}
|
||||
|
||||
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) => new()
|
||||
{
|
||||
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) => new(
|
||||
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) =>
|
||||
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) =>
|
||||
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) => new(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) => new(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) => new(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) => new(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) => new(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) => new(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) => new() { 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) => new(message, code);
|
||||
private static string NormalizeImportFormat(string? value) => 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) => value?.Trim().ToLowerInvariant() switch { "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active };
|
||||
private static QuestionStatus ParseQuestionStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, _ => QuestionStatus.Published };
|
||||
private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) => value?.Trim().ToLowerInvariant() switch { "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback };
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static JsonElement ObjectOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||||
private static JsonElement ArrayOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
|
||||
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
private static string? GetString(JsonElement value, string name) => 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) => 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) => 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);
|
||||
}
|
||||
Reference in New Issue
Block a user