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