191 lines
9.5 KiB
C#
191 lines
9.5 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Assets;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Application.PlatformAdmin;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.QuestionBanks;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.PlatformAdmin;
|
|
|
|
internal abstract partial class PlatformQuestionBankServiceBase
|
|
{
|
|
protected Task<PlatformQuestionPage> GetQuestionsCoreAsync(
|
|
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);
|
|
}
|
|
|
|
protected Task<PlatformQuestionItem> UpsertQuestionCoreAsync(
|
|
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);
|
|
}
|
|
|
|
protected Task<int> ArchiveQuestionsCoreAsync(
|
|
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);
|
|
}
|
|
|
|
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,
|
|
QuestionType = Normalize(command.Type) ?? "choice",
|
|
TypeLabel = Normalize(command.TypeLabel),
|
|
Difficulty = command.Difficulty,
|
|
Tags = ArrayOrDefault(command.Tags),
|
|
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
|
|
};
|
|
}
|
|
|
|
}
|