diff --git a/Tiku.Api/Contracts/AssetManagementDtos.cs b/Tiku.Api/Contracts/AssetManagementDtos.cs index 19b569d..1ce3894 100644 --- a/Tiku.Api/Contracts/AssetManagementDtos.cs +++ b/Tiku.Api/Contracts/AssetManagementDtos.cs @@ -112,6 +112,83 @@ public sealed class AssetUploadConfirmDto } } +public sealed class UpsertAssetDto +{ + public Guid? AssetId { get; set; } + public Guid? RegionId { get; set; } + public Guid? SubjectId { get; set; } + public Guid? CategoryId { get; set; } + public Guid? ContentNodeId { get; set; } + public string? LegacyId { get; set; } + public string? AssetKey { get; set; } + public string? Title { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public string? FileName { get; set; } + public string? CdnUrl { get; set; } + public bool? IsPublic { get; set; } + public string? AssetType { get; set; } + public string? Visibility { get; set; } + public string? Status { get; set; } + public string? Provider { get; set; } + public string? Bucket { get; set; } + public string? ObjectKey { get; set; } + public string? MimeType { get; set; } + public long? FileSizeBytes { get; set; } + public string? ChecksumSha256 { get; set; } + public string? PreviewUrl { get; set; } + public string? PreviewObjectKey { get; set; } + public int? Order { get; set; } + public JsonElement AccessRules { get; set; } = JsonDefaults.Object(); + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpsertAssetCommand ToCommand() + { + return new UpsertAssetCommand( + AssetId, + RegionId, + SubjectId, + CategoryId, + ContentNodeId, + LegacyId, + AssetKey, + Title, + Category, + Description, + FileName, + CdnUrl, + IsPublic, + AssetType, + Visibility, + Status, + Provider, + Bucket, + ObjectKey, + MimeType, + FileSizeBytes, + ChecksumSha256, + PreviewUrl, + PreviewObjectKey, + Order, + AccessRules, + Metadata); + } +} + +public sealed class AssetAccessSignDto +{ + [Required] + public Guid AssetId { get; set; } + + [Range(60, 3600)] + public int? ExpiresInSeconds { get; set; } + + public AssetAccessSignCommand ToCommand() + { + return new AssetAccessSignCommand(AssetId, ExpiresInSeconds); + } +} + public sealed class AssetManagementQueryDto { public Guid? RegionId { get; set; } @@ -156,6 +233,20 @@ public sealed class AssetManagementQueryDto } } +public sealed class AssetEventQueryDto +{ + public Guid? AssetId { get; set; } + public Guid? UserId { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public AssetEventFilter ToFilter() + { + return new AssetEventFilter(AssetId, UserId, Limit); + } +} + public sealed class ImportJobQueryDto { [StringLength(50)] diff --git a/Tiku.Api/Contracts/DirectContentDtos.cs b/Tiku.Api/Contracts/DirectContentDtos.cs new file mode 100644 index 0000000..ad491d1 --- /dev/null +++ b/Tiku.Api/Contracts/DirectContentDtos.cs @@ -0,0 +1,449 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Tiku.Application.Content; +using Tiku.Domain.Common; + +namespace Tiku.Api.Contracts; + +public sealed class DirectContentQueryDto +{ + public Guid? RegionId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public Guid? ParentId { get; set; } + public Guid? SubjectId { get; set; } + public Guid? ChapterId { get; set; } + public Guid? UnitId { get; set; } + public Guid? SchoolId { get; set; } + public Guid? MajorId { get; set; } + public Guid? QuestionId { get; set; } + + [StringLength(50)] + public string? Status { get; set; } + + [StringLength(200)] + public string? Keyword { get; set; } + + [Range(1900, 3000)] + public int? Year { get; set; } + + [Range(1, 1000)] + public int? Limit { get; set; } + + public AdminLimitFilter ToFilter() + { + return new AdminLimitFilter( + RegionId, + EntryId, + ContentNodeId, + ParentId, + SubjectId, + ChapterId, + UnitId, + SchoolId, + MajorId, + QuestionId, + Status, + Keyword, + Year, + Limit); + } +} + +public sealed class DirectQuestionWriteDto +{ + public Guid? QuestionId { get; set; } + public Guid? QuestionBankId { get; set; } + public Guid? SubjectId { get; set; } + public Guid? CategoryId { get; set; } + public Guid? NodeId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public Guid? PrimaryCollectionId { get; set; } + public string? LegacyId { get; set; } + public string? Type { get; set; } + public string? TypeLabel { get; set; } + + [Range(1, 5)] + public int? Difficulty { get; set; } + + public JsonElement Tags { get; set; } = JsonDefaults.Array(); + public string? Content { get; set; } + public JsonElement Options { get; set; } = JsonDefaults.Array(); + public int? CorrectOptionIndex { get; set; } + public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array(); + public string? AnswerText { get; set; } + public string? Explanation { get; set; } + public JsonElement SubQuestions { get; set; } = JsonDefaults.Array(); + public string? CodeLang { get; set; } + public string? CodeTemplate { get; set; } + public string? MediaUrl { get; set; } + public string? Status { get; set; } + public JsonElement ExamMarkers { get; set; } = JsonDefaults.Object(); + public string? SourceHash { get; set; } + public bool? CreateVersion { get; set; } + + public QuestionWriteCommand ToCommand(bool createVersionDefault) + { + return new QuestionWriteCommand( + QuestionId, + QuestionBankId, + SubjectId, + CategoryId, + NodeId, + EntryId, + ContentNodeId, + PrimaryCollectionId, + LegacyId, + Type, + TypeLabel, + Difficulty, + Tags, + Content, + Options, + CorrectOptionIndex, + CorrectOptionIndices, + AnswerText, + Explanation, + SubQuestions, + CodeLang, + CodeTemplate, + MediaUrl, + Status, + ExamMarkers, + SourceHash, + CreateVersion ?? createVersionDefault); + } +} + +public sealed class DirectVocabularyUnitDto +{ + public Guid? Id { get; set; } + public Guid? RegionId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public string? LegacyId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public int? WordCount { get; set; } + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public VocabularyUnitCommand ToCommand() + { + return new VocabularyUnitCommand(Id, RegionId, EntryId, ContentNodeId, LegacyId, Name, Description, WordCount, Order, IsActive, Metadata); + } +} + +public sealed class DirectVocabularyWordDto +{ + public Guid? Id { get; set; } + public Guid? UnitId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public string? LegacyId { get; set; } + public required string Word { get; set; } + public string? Phonetic { get; set; } + public string? Meaning { get; set; } + public string? Example { get; set; } + public string? ExampleTranslation { get; set; } + public int? Difficulty { get; set; } + public JsonElement Tags { get; set; } = JsonDefaults.Array(); + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public VocabularyWordCommand ToCommand() + { + return new VocabularyWordCommand( + Id, + UnitId, + EntryId, + ContentNodeId, + LegacyId, + Word, + Phonetic, + Meaning, + Example, + ExampleTranslation, + Difficulty, + Tags, + Order, + IsActive, + Metadata); + } +} + +public sealed class DirectHandbookSubjectDto +{ + public Guid? Id { get; set; } + public Guid? RegionId { get; set; } + public Guid? SchoolId { get; set; } + public Guid? MajorId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public string? LegacyId { get; set; } + public required string Name { get; set; } + public string? Type { get; set; } + public string? Icon { get; set; } + public string? Color { get; set; } + public string? Description { get; set; } + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public HandbookSubjectCommand ToCommand() + { + return new HandbookSubjectCommand( + Id, + RegionId, + SchoolId, + MajorId, + EntryId, + ContentNodeId, + LegacyId, + Name, + Type, + Icon, + Color, + Description, + Order, + IsActive, + Metadata); + } +} + +public sealed class DirectHandbookChapterDto +{ + public Guid? Id { get; set; } + public Guid? SubjectId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public string? LegacyId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public HandbookChapterCommand ToCommand() + { + return new HandbookChapterCommand(Id, SubjectId, EntryId, ContentNodeId, LegacyId, Name, Description, Order, IsActive, Metadata); + } +} + +public sealed class DirectHandbookEntryDto +{ + public Guid? Id { get; set; } + public Guid? ChapterId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public string? LegacyId { get; set; } + public required string Title { get; set; } + public string? Summary { get; set; } + public string? Content { get; set; } + public JsonElement Tags { get; set; } = JsonDefaults.Array(); + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public HandbookEntryCommand ToCommand() + { + return new HandbookEntryCommand(Id, ChapterId, EntryId, ContentNodeId, LegacyId, Title, Summary, Content, Tags, Order, IsActive, Metadata); + } +} + +public sealed class DirectSchoolDto +{ + public Guid? Id { get; set; } + public Guid? RegionId { get; set; } + public string? LegacyId { get; set; } + public required string Name { get; set; } + public string? ProfessionalExamDate { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public SchoolCommand ToCommand() + { + return new SchoolCommand(Id, RegionId, LegacyId, Name, ProfessionalExamDate, Metadata); + } +} + +public sealed class DirectMajorDto +{ + public Guid? Id { get; set; } + public Guid? RegionId { get; set; } + public Guid? SchoolId { get; set; } + public string? LegacyId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? StudyTips { get; set; } + public int? Order { get; set; } + public bool? IsActive { get; set; } + + public MajorCommand ToCommand() + { + return new MajorCommand(Id, RegionId, SchoolId, LegacyId, Name, Description, StudyTips, Order, IsActive); + } +} + +public sealed class DirectVideoDto +{ + public Guid? Id { get; set; } + public Guid? SubjectId { get; set; } + public string? LegacyId { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public string? VideoUrl { get; set; } + public string? ThumbnailUrl { get; set; } + public int? DurationSeconds { get; set; } + public JsonElement KnowledgeTags { get; set; } = JsonDefaults.Array(); + public bool? IsGeneral { get; set; } + public int? Difficulty { get; set; } + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public VideoExplanationCommand ToCommand() + { + return new VideoExplanationCommand( + Id, + SubjectId, + LegacyId, + Title, + Description, + VideoUrl, + ThumbnailUrl, + DurationSeconds, + KnowledgeTags, + IsGeneral, + Difficulty, + Order, + IsActive, + Metadata); + } +} + +public sealed class DirectQuestionVideoDto +{ + public Guid QuestionId { get; set; } + public Guid VideoId { get; set; } + public string? LegacyId { get; set; } + public string? VideoType { get; set; } + public int? Order { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public QuestionVideoCommand ToCommand() + { + return new QuestionVideoCommand(QuestionId, VideoId, LegacyId, VideoType, Order, Metadata); + } +} + +public sealed class DirectOperationContentDto +{ + public Guid? Id { get; set; } + public Guid? RegionId { get; set; } + public Guid? SchoolId { get; set; } + public string? LegacyId { get; set; } + public string? Title { get; set; } + public string? Subtitle { get; set; } + public string? Content { get; set; } + public string? Question { get; set; } + public string? Answer { get; set; } + public string? Link { get; set; } + public string? ButtonText { get; set; } + public string? ButtonLink { get; set; } + public string? BackgroundColor { get; set; } + public string? BorderColor { get; set; } + public string? ExamName { get; set; } + public DateTimeOffset? ExamAt { get; set; } + public string? ExamType { get; set; } + public string? Description { get; set; } + public int? Order { get; set; } + public bool? IsActive { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public OperationContentCommand ToCommand() + { + return new OperationContentCommand( + Id, + RegionId, + SchoolId, + LegacyId, + Title, + Subtitle, + Content, + Question, + Answer, + Link, + ButtonText, + ButtonLink, + BackgroundColor, + BorderColor, + ExamName, + ExamAt, + ExamType, + Description, + Order, + IsActive, + Metadata); + } +} + +public sealed class DirectImportDto +{ + public string? SourceFormat { get; set; } + public string? SourceName { get; set; } + public Guid? RegionId { get; set; } + public Guid? EntryId { get; set; } + public Guid? ContentNodeId { get; set; } + public Guid? SubjectId { get; set; } + public Guid? CategoryId { get; set; } + public Guid? QuestionBankId { get; set; } + public Guid? CollectionId { get; set; } + public IReadOnlyCollection? Items { get; set; } + public IReadOnlyCollection? Units { get; set; } + public IReadOnlyCollection? Words { get; set; } + public IReadOnlyCollection? Subjects { get; set; } + public IReadOnlyCollection? Entries { get; set; } + public IReadOnlyCollection? Fields { get; set; } + public IReadOnlyCollection? Schools { get; set; } + public IReadOnlyCollection? Majors { get; set; } + public IReadOnlyCollection? Records { get; set; } + public IReadOnlyCollection? Videos { get; set; } + + public SimpleImportCommand ToCommand(string importType, bool dryRun) + { + return new SimpleImportCommand( + importType, + SourceFormat, + SourceName, + RegionId, + EntryId, + ContentNodeId, + SubjectId, + CategoryId, + QuestionBankId, + CollectionId, + ResolveItems(importType), + dryRun); + } + + private IReadOnlyCollection ResolveItems(string importType) + { + return importType.ToLowerInvariant() switch + { + "questions" => Items ?? [], + "vocabulary" => Words ?? Units ?? Items ?? [], + "handbook" => Entries ?? Subjects ?? Items ?? [], + "scoreline" => Records ?? Schools ?? Majors ?? Fields ?? Items ?? [], + "videos" => Videos ?? Items ?? [], + _ => Items ?? [] + }; + } +} + +public sealed class DirectImportJobDto +{ + public Guid JobId { get; set; } +} diff --git a/Tiku.Api/Contracts/LearningDtos.cs b/Tiku.Api/Contracts/LearningDtos.cs index c8d566e..8e293cd 100644 --- a/Tiku.Api/Contracts/LearningDtos.cs +++ b/Tiku.Api/Contracts/LearningDtos.cs @@ -184,3 +184,19 @@ public sealed class FavoriteWordDto return new FavoriteWordCommand(WordId, Favorite, Note); } } + +public sealed class WordReviewDto +{ + [Required] + public Guid WordId { get; set; } + + [StringLength(50)] + public string? Result { get; set; } + + public DateTimeOffset? NextReviewAt { get; set; } + + public WordReviewCommand ToCommand() + { + return new WordReviewCommand(WordId, Result, NextReviewAt); + } +} diff --git a/Tiku.Api/Controllers/LearningController.cs b/Tiku.Api/Controllers/LearningController.cs index 6032f2f..05e096f 100644 --- a/Tiku.Api/Controllers/LearningController.cs +++ b/Tiku.Api/Controllers/LearningController.cs @@ -15,6 +15,34 @@ public sealed class LearningController( ICurrentUser currentUser, ICurrentTenant currentTenant) : ControllerBase { + [HttpGet("stats")] + [EndpointSummary("查询学习统计")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetStats(CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetStatsAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("trend")] + [EndpointSummary("查询学习趋势")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetTrend( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpGet("leaderboard")] + [EndpointSummary("查询学习排行榜")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetLeaderboard( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + [HttpPost("practice-sessions")] [EndpointSummary("创建练习会话")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -153,6 +181,19 @@ public sealed class LearningController( cancellationToken)); } + [HttpGet("wrong-questions/review-plan")] + [EndpointSummary("生成错题复习计划")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWrongQuestionReviewPlan( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetWrongQuestionReviewPlanAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + [HttpPost("wrong-questions/resolve")] [EndpointSummary("将错题标记为已解决")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -180,6 +221,19 @@ public sealed class LearningController( cancellationToken)); } + [HttpGet("vocabulary/review-plan")] + [EndpointSummary("生成单词复习计划")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWordReviewPlan( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetWordReviewPlanAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + [HttpPost("vocabulary/progress")] [EndpointSummary("更新单词学习进度")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -194,6 +248,33 @@ public sealed class LearningController( cancellationToken)); } + [HttpPost("vocabulary/review")] + [EndpointSummary("提交单词复习结果")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ReviewWord( + WordReviewDto request, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.ReviewWordAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("vocabulary/stats")] + [EndpointSummary("查询单词学习统计")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWordStats( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetWordStatsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + [HttpGet("vocabulary/favorites")] [EndpointSummary("查询收藏单词")] [ProducesResponseType>(StatusCodes.Status200OK)] diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs index d4cf11c..dff23c5 100644 --- a/Tiku.Api/Controllers/TenantContentController.cs +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -166,6 +166,45 @@ public sealed class TenantContentController( cancellationToken)); } + [HttpGet("assets/access-events")] + [EndpointSummary("查询资产访问审计事件")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetAssetAccessEvents( + [FromQuery] AssetEventQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.GetAccessEventsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("assets/security-scan-events")] + [EndpointSummary("查询资产安全扫描事件")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetAssetSecurityScanEvents( + [FromQuery] AssetEventQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.GetSecurityScanEventsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPut("assets")] + [EndpointSummary("新增或更新内容资产")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertAsset( + UpsertAssetDto request, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.UpsertAssetAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + [HttpPost("assets/uploads/sign")] [EndpointSummary("创建资产上传签名")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -196,6 +235,32 @@ public sealed class TenantContentController( cancellationToken)); } + [HttpPost("assets/sign-download")] + [EndpointSummary("签发管理侧资产下载地址")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SignAssetDownload( + AssetAccessSignDto request, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.SignDownloadAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("assets/sign-preview")] + [EndpointSummary("签发管理侧资产预览地址")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SignAssetPreview( + AssetAccessSignDto request, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.SignPreviewAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + [HttpGet("import-jobs")] [EndpointSummary("查询内容导入任务")] [ProducesResponseType>(StatusCodes.Status200OK)] diff --git a/Tiku.Api/Controllers/TenantContentDirectController.cs b/Tiku.Api/Controllers/TenantContentDirectController.cs new file mode 100644 index 0000000..2dc3354 --- /dev/null +++ b/Tiku.Api/Controllers/TenantContentDirectController.cs @@ -0,0 +1,315 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Produces("application/json")] +[Route("api/tenant-content")] +public sealed class TenantContentDirectController( + IDirectContentService directContentService, + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [HttpPost("questions")] + [EndpointSummary("创建题目及首个版本")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> CreateQuestion( + DirectQuestionWriteDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.CreateQuestionAsync(ResolveActor(), request.ToCommand(createVersionDefault: true), cancellationToken)); + } + + [HttpPatch("questions")] + [EndpointSummary("更新题目并可选择创建新版本")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpdateQuestion( + DirectQuestionWriteDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpdateQuestionAsync(ResolveActor(), request.ToCommand(createVersionDefault: false), cancellationToken)); + } + + [HttpGet("vocabulary-units")] + [EndpointSummary("查询管理侧词汇单元")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetVocabularyUnits( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetVocabularyUnitsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("vocabulary-units")] + [EndpointSummary("新增或更新词汇单元")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertVocabularyUnit( + DirectVocabularyUnitDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertVocabularyUnitAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("vocabulary-words")] + [EndpointSummary("查询管理侧词汇")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetVocabularyWords( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetVocabularyWordsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("vocabulary-words")] + [EndpointSummary("新增或更新词汇")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertVocabularyWord( + DirectVocabularyWordDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertVocabularyWordAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("handbook-subjects")] + [EndpointSummary("查询管理侧知识手册科目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetHandbookSubjects( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetHandbookSubjectsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("handbook-subjects")] + [EndpointSummary("新增或更新知识手册科目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertHandbookSubject( + DirectHandbookSubjectDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertHandbookSubjectAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("handbook-chapters")] + [EndpointSummary("查询管理侧知识手册章节")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetHandbookChapters( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetHandbookChaptersAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("handbook-chapters")] + [EndpointSummary("新增或更新知识手册章节")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertHandbookChapter( + DirectHandbookChapterDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertHandbookChapterAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("handbook-entries")] + [EndpointSummary("查询管理侧知识手册条目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetHandbookEntries( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetHandbookEntriesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("handbook-entries")] + [EndpointSummary("新增或更新知识手册条目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertHandbookEntry( + DirectHandbookEntryDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertHandbookEntryAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("scoreline/schools")] + [EndpointSummary("查询管理侧分数线院校")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetSchools( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetSchoolsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("scoreline/schools")] + [EndpointSummary("新增或更新分数线院校")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertSchool( + DirectSchoolDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertSchoolAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("scoreline/majors")] + [EndpointSummary("查询管理侧分数线专业")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetMajors( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetMajorsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("scoreline/majors")] + [EndpointSummary("新增或更新分数线专业")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertMajor( + DirectMajorDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertMajorAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("scoreline/years")] + [EndpointSummary("查询分数线年份")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetScorelineYears( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetScorelineYearsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpGet("scoreline/trend")] + [EndpointSummary("查询分数线趋势摘要")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetScorelineTrend( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetScorelineTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpGet("videos")] + [EndpointSummary("查询租户视频解析")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetVideos( + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetVideosAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("videos")] + [EndpointSummary("新增或更新视频解析")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertVideo( + DirectVideoDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("question-videos")] + [EndpointSummary("绑定题目与解析视频")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> BindQuestionVideo( + DirectQuestionVideoDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.BindQuestionVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("operations/{kind}")] + [EndpointSummary("查询运营内容")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetOperationContent( + string kind, + [FromQuery] DirectContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetOperationContentAsync(ResolveActor(), kind, query.ToFilter(), cancellationToken)); + } + + [HttpPut("operations/{kind}")] + [EndpointSummary("新增或更新运营内容")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertOperationContent( + string kind, + DirectOperationContentDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.UpsertOperationContentAsync(ResolveActor(), kind, request.ToCommand(), cancellationToken)); + } + + [HttpPost("imports/preview/{importType}")] + [EndpointSummary("预览内容导入数据")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PreviewImport( + string importType, + DirectImportDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.PreviewImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: true), cancellationToken)); + } + + [HttpPost("imports/{importType}")] + [EndpointSummary("执行同步内容导入")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ExecuteImport( + string importType, + DirectImportDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.ExecuteImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: false), cancellationToken)); + } + + [HttpGet("imports/issues")] + [EndpointSummary("查询内容导入问题明细")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetImportIssues( + [FromQuery] DirectImportJobDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetImportIssuesAsync(ResolveActor(), query.JobId, cancellationToken)); + } + + [HttpPost("imports/post-check")] + [EndpointSummary("执行内容导入后完整性检查")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RunImportPostCheck( + DirectImportJobDto request, + CancellationToken cancellationToken) + { + return Ok(await directContentService.RunImportPostCheckAsync(ResolveActor(), request.JobId, cancellationToken)); + } + + [HttpGet("imports/post-check")] + [EndpointSummary("查询内容导入后检查状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetImportPostCheck( + [FromQuery] DirectImportJobDto query, + CancellationToken cancellationToken) + { + return Ok(await directContentService.GetImportPostCheckAsync(ResolveActor(), query.JobId, cancellationToken)); + } + + private DirectContentActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new ContentManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied"); + } + + return new DirectContentActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } +} diff --git a/Tiku.Application/Assets/AssetManagementModels.cs b/Tiku.Application/Assets/AssetManagementModels.cs index cfd7075..7a3a08b 100644 --- a/Tiku.Application/Assets/AssetManagementModels.cs +++ b/Tiku.Application/Assets/AssetManagementModels.cs @@ -36,6 +36,37 @@ public sealed record AssetUploadConfirmCommand( long? FileSizeBytes, string? ChecksumSha256); +public sealed record UpsertAssetCommand( + Guid? AssetId, + Guid? RegionId, + Guid? SubjectId, + Guid? CategoryId, + Guid? ContentNodeId, + string? LegacyId, + string? AssetKey, + string? Title, + string? Category, + string? Description, + string? FileName, + string? CdnUrl, + bool? IsPublic, + string? AssetType, + string? Visibility, + string? Status, + string? Provider, + string? Bucket, + string? ObjectKey, + string? MimeType, + long? FileSizeBytes, + string? ChecksumSha256, + string? PreviewUrl, + string? PreviewObjectKey, + int? Order, + JsonElement AccessRules, + JsonElement Metadata); + +public sealed record AssetAccessSignCommand(Guid AssetId, int? ExpiresInSeconds); + public sealed record AssetManagementFilter( Guid? RegionId = null, Guid? SubjectId = null, @@ -54,6 +85,8 @@ public sealed record ImportJobFilter( string? SourceFormat = null, int? Limit = null); +public sealed record AssetEventFilter(Guid? AssetId = null, Guid? UserId = null, int? Limit = null); + public sealed record ContentAssetManagementItem( Guid Id, string? LegacyId, @@ -96,6 +129,39 @@ public sealed record AssetUploadConfirmResult( ContentAssetManagementItem Item, ObjectStorageMetadata Metadata); +public sealed record AssetManagementSignedAccessResult( + ContentAssetManagementItem Item, + ObjectStorageSignedUrl Url); + +public sealed record ContentAssetAccessEventItem( + Guid Id, + Guid? AssetId, + Guid? UserId, + AssetAccessActorRole ActorRole, + AssetAccessType AccessType, + string? Visibility, + string? AssetType, + string? StorageProvider, + AssetAccessDisposition? Disposition, + int? ExpiresInSeconds, + string? SignatureMode, + AssetAccessResult Result, + string? DenyCode, + string? IpAddress, + string? UserAgent, + JsonElement Metadata, + DateTimeOffset CreatedAt); + +public sealed record ContentAssetSecurityScanEventItem( + Guid Id, + Guid? AssetId, + string Provider, + AssetSecurityScanStatus ScanStatus, + AssetSecurityRiskLevel RiskLevel, + string[] IssueCodes, + JsonElement Details, + DateTimeOffset CreatedAt); + public sealed record ContentImportJobItem( Guid Id, Guid? TargetRegionId, diff --git a/Tiku.Application/Assets/IAssetManagementService.cs b/Tiku.Application/Assets/IAssetManagementService.cs index 4315f12..2140328 100644 --- a/Tiku.Application/Assets/IAssetManagementService.cs +++ b/Tiku.Application/Assets/IAssetManagementService.cs @@ -1,4 +1,5 @@ using Tiku.Application.Catalog; +using Tiku.Application.Content; namespace Tiku.Application.Assets; @@ -9,6 +10,11 @@ public interface IAssetManagementService AssetManagementFilter filter, CancellationToken cancellationToken = default); + Task> UpsertAssetAsync( + AssetManagementActor actor, + UpsertAssetCommand command, + CancellationToken cancellationToken = default); + Task SignUploadAsync( AssetManagementActor actor, AssetUploadSignCommand command, @@ -19,6 +25,26 @@ public interface IAssetManagementService AssetUploadConfirmCommand command, CancellationToken cancellationToken = default); + Task SignDownloadAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + CancellationToken cancellationToken = default); + + Task SignPreviewAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + CancellationToken cancellationToken = default); + + Task> GetAccessEventsAsync( + AssetManagementActor actor, + AssetEventFilter filter, + CancellationToken cancellationToken = default); + + Task> GetSecurityScanEventsAsync( + AssetManagementActor actor, + AssetEventFilter filter, + CancellationToken cancellationToken = default); + Task> GetImportJobsAsync( AssetManagementActor actor, ImportJobFilter filter, diff --git a/Tiku.Application/Content/DirectContentModels.cs b/Tiku.Application/Content/DirectContentModels.cs new file mode 100644 index 0000000..aac766c --- /dev/null +++ b/Tiku.Application/Content/DirectContentModels.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Domain.Catalog; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; + +namespace Tiku.Application.Content; + +public sealed record DirectContentActor(Guid TenantId, Guid UserId); + +public sealed record AdminLimitFilter( + Guid? RegionId = null, + Guid? EntryId = null, + Guid? ContentNodeId = null, + Guid? ParentId = null, + Guid? SubjectId = null, + Guid? ChapterId = null, + Guid? UnitId = null, + Guid? SchoolId = null, + Guid? MajorId = null, + Guid? QuestionId = null, + string? Status = null, + string? Keyword = null, + int? Year = null, + int? Limit = null); + +public sealed record QuestionWriteCommand( + Guid? QuestionId, + Guid? QuestionBankId, + Guid? SubjectId, + Guid? CategoryId, + Guid? NodeId, + Guid? EntryId, + Guid? ContentNodeId, + Guid? PrimaryCollectionId, + string? LegacyId, + string? Type, + string? TypeLabel, + int? Difficulty, + JsonElement Tags, + string? Content, + JsonElement Options, + int? CorrectOptionIndex, + JsonElement CorrectOptionIndices, + string? AnswerText, + string? Explanation, + JsonElement SubQuestions, + string? CodeLang, + string? CodeTemplate, + string? MediaUrl, + string? Status, + JsonElement ExamMarkers, + string? SourceHash, + bool CreateVersion); + +public sealed record QuestionManagementItem( + Guid Id, + Guid? VersionId, + Guid? QuestionBankId, + Guid? SubjectId, + Guid? CategoryId, + Guid? NodeId, + Guid? EntryId, + Guid? ContentNodeId, + Guid? PrimaryCollectionId, + string? LegacyId, + string Type, + string? TypeLabel, + int? Difficulty, + JsonElement Tags, + string? Content, + JsonElement Options, + int? CorrectOptionIndex, + JsonElement CorrectOptionIndices, + string? AnswerText, + string? Explanation, + JsonElement SubQuestions, + string? CodeLang, + string? CodeTemplate, + string? MediaUrl, + bool HasVideoExplanation, + QuestionStatus Status); + +public sealed record VocabularyUnitCommand( + Guid? Id, + Guid? RegionId, + Guid? EntryId, + Guid? ContentNodeId, + string? LegacyId, + string Name, + string? Description, + int? WordCount, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record VocabularyWordCommand( + Guid? Id, + Guid? UnitId, + Guid? EntryId, + Guid? ContentNodeId, + string? LegacyId, + string Word, + string? Phonetic, + string? Meaning, + string? Example, + string? ExampleTranslation, + int? Difficulty, + JsonElement Tags, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record HandbookSubjectCommand( + Guid? Id, + Guid? RegionId, + Guid? SchoolId, + Guid? MajorId, + Guid? EntryId, + Guid? ContentNodeId, + string? LegacyId, + string Name, + string? Type, + string? Icon, + string? Color, + string? Description, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record HandbookChapterCommand( + Guid? Id, + Guid? SubjectId, + Guid? EntryId, + Guid? ContentNodeId, + string? LegacyId, + string Name, + string? Description, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record HandbookEntryCommand( + Guid? Id, + Guid? ChapterId, + Guid? EntryId, + Guid? ContentNodeId, + string? LegacyId, + string Title, + string? Summary, + string? Content, + JsonElement Tags, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record SchoolCommand( + Guid? Id, + Guid? RegionId, + string? LegacyId, + string Name, + string? ProfessionalExamDate, + JsonElement Metadata); + +public sealed record MajorCommand( + Guid? Id, + Guid? RegionId, + Guid? SchoolId, + string? LegacyId, + string Name, + string? Description, + string? StudyTips, + int? Order, + bool? IsActive); + +public sealed record VideoExplanationCommand( + Guid? Id, + Guid? SubjectId, + string? LegacyId, + string Title, + string? Description, + string? VideoUrl, + string? ThumbnailUrl, + int? DurationSeconds, + JsonElement KnowledgeTags, + bool? IsGeneral, + int? Difficulty, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record QuestionVideoCommand( + Guid QuestionId, + Guid VideoId, + string? LegacyId, + string? VideoType, + int? Order, + JsonElement Metadata); + +public sealed record OperationContentCommand( + Guid? Id, + Guid? RegionId, + Guid? SchoolId, + string? LegacyId, + string? Title, + string? Subtitle, + string? Content, + string? Question, + string? Answer, + string? Link, + string? ButtonText, + string? ButtonLink, + string? BackgroundColor, + string? BorderColor, + string? ExamName, + DateTimeOffset? ExamAt, + string? ExamType, + string? Description, + int? Order, + bool? IsActive, + JsonElement Metadata); + +public sealed record SimpleImportCommand( + string ImportType, + string? SourceFormat, + string? SourceName, + Guid? RegionId, + Guid? EntryId, + Guid? ContentNodeId, + Guid? SubjectId, + Guid? CategoryId, + Guid? QuestionBankId, + Guid? CollectionId, + IReadOnlyCollection Items, + bool DryRun); + +public sealed record SimpleImportResult( + ContentImportJobItem Job, + IReadOnlyCollection Items, + IReadOnlyCollection Issues); + +public sealed record ImportPostCheckResult(Guid JobId, string Status, JsonElement Counts, IReadOnlyCollection Issues); + +public sealed record VideoManagementItem( + Guid Id, + Guid? SubjectId, + string? LegacyId, + string Title, + string? Description, + string? VideoUrl, + string? ThumbnailUrl, + int? DurationSeconds, + JsonElement KnowledgeTags, + bool IsGeneral, + int? Difficulty, + int Order, + bool IsActive, + JsonElement Metadata); + +public sealed record QuestionVideoManagementItem( + Guid Id, + Guid? QuestionId, + Guid? VideoId, + string? LegacyId, + QuestionVideoType VideoType, + int Order, + JsonElement Metadata); + +public sealed record OperationContentItem( + Guid Id, + string Kind, + Guid? RegionId, + Guid? SchoolId, + string? LegacyId, + string? Title, + string? Content, + string? Question, + string? Answer, + DateTimeOffset? ExamAt, + string? ExamType, + int Order, + bool IsActive, + JsonElement Metadata); + +public sealed record ScorelineTrendItem(int Year, int SchoolCount, int MajorCount); + +public interface IDirectContentService +{ + Task> CreateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default); + Task> UpdateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default); + Task> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertVocabularyUnitAsync(DirectContentActor actor, VocabularyUnitCommand command, CancellationToken cancellationToken = default); + Task> GetVocabularyWordsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertVocabularyWordAsync(DirectContentActor actor, VocabularyWordCommand command, CancellationToken cancellationToken = default); + Task> GetHandbookSubjectsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertHandbookSubjectAsync(DirectContentActor actor, HandbookSubjectCommand command, CancellationToken cancellationToken = default); + Task> GetHandbookChaptersAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertHandbookChapterAsync(DirectContentActor actor, HandbookChapterCommand command, CancellationToken cancellationToken = default); + Task> GetHandbookEntriesAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertHandbookEntryAsync(DirectContentActor actor, HandbookEntryCommand command, CancellationToken cancellationToken = default); + Task> GetSchoolsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertSchoolAsync(DirectContentActor actor, SchoolCommand command, CancellationToken cancellationToken = default); + Task> GetMajorsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertMajorAsync(DirectContentActor actor, MajorCommand command, CancellationToken cancellationToken = default); + Task> GetScorelineYearsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertVideoAsync(DirectContentActor actor, VideoExplanationCommand command, CancellationToken cancellationToken = default); + Task> BindQuestionVideoAsync(DirectContentActor actor, QuestionVideoCommand command, CancellationToken cancellationToken = default); + Task> GetOperationContentAsync(DirectContentActor actor, string kind, AdminLimitFilter filter, CancellationToken cancellationToken = default); + Task> UpsertOperationContentAsync(DirectContentActor actor, string kind, OperationContentCommand command, CancellationToken cancellationToken = default); + Task PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default); + Task ExecuteImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default); + Task> GetImportIssuesAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); + Task RunImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); + Task GetImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Learning/ILearningActivityService.cs b/Tiku.Application/Learning/ILearningActivityService.cs index 553c13b..3e9b5d7 100644 --- a/Tiku.Application/Learning/ILearningActivityService.cs +++ b/Tiku.Application/Learning/ILearningActivityService.cs @@ -2,6 +2,20 @@ namespace Tiku.Application.Learning; public interface ILearningActivityService { + Task GetStatsAsync( + LearningActor actor, + CancellationToken cancellationToken = default); + + Task> GetTrendAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + + Task GetLeaderboardAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task SubmitAnswerAsync( LearningActor actor, SubmitAnswerCommand command, @@ -22,6 +36,11 @@ public interface ILearningActivityService LearningLimitFilter filter, CancellationToken cancellationToken = default); + Task GetWrongQuestionReviewPlanAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task ResolveWrongQuestionAsync( LearningActor actor, QuestionActionCommand command, @@ -32,11 +51,26 @@ public interface ILearningActivityService LearningLimitFilter filter, CancellationToken cancellationToken = default); + Task GetWordReviewPlanAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task UpdateWordProgressAsync( LearningActor actor, WordProgressCommand command, CancellationToken cancellationToken = default); + Task ReviewWordAsync( + LearningActor actor, + WordReviewCommand command, + CancellationToken cancellationToken = default); + + Task GetWordStatsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task> GetFavoriteWordsAsync( LearningActor actor, LearningLimitFilter filter, diff --git a/Tiku.Application/Learning/LearningActivityModels.cs b/Tiku.Application/Learning/LearningActivityModels.cs index 0a9fe0d..e7e30ec 100644 --- a/Tiku.Application/Learning/LearningActivityModels.cs +++ b/Tiku.Application/Learning/LearningActivityModels.cs @@ -28,6 +28,62 @@ public sealed record FavoriteWordCommand(Guid WordId, bool? Favorite, string? No public sealed record LearningLimitFilter(int? Limit = null, string? Status = null, Guid? UnitId = null); +public sealed record LearningStatsItem( + int AnswerCount, + int CorrectCount, + int WrongCount, + int FavoriteQuestionCount, + int FavoriteWordCount, + int WordProgressCount, + int PracticeSessionCount, + int PracticeReportCount); + +public sealed record LearningTrendItem(DateOnly Date, int AnswerCount, int CorrectCount, int WrongCount); + +public sealed record LearningLeaderboardItem( + Guid UserId, + string? DisplayName, + int AnswerCount, + int CorrectCount, + int WrongCount, + decimal Accuracy); + +public sealed record LearningLeaderboardResult( + string Metric, + string Period, + IReadOnlyCollection Items, + LearningLeaderboardItem? CurrentUser, + DateTimeOffset GeneratedAt); + +public sealed record WrongQuestionReviewPlanItem(Guid QuestionId, int WrongCount, DateTimeOffset LastWrongAt); + +public sealed record WrongQuestionReviewPlan( + IReadOnlyCollection Items, + JsonElement NextAction); + +public sealed record WordReviewPlanItem( + Guid WordId, + WordProgressStatus Status, + DateTimeOffset? NextReviewAt, + int CorrectCount, + int WrongCount, + WordDueLevel DueLevel); + +public sealed record WordReviewPlan( + IReadOnlyCollection Items, + JsonElement NextAction); + +public sealed record WordReviewCommand(Guid WordId, string? Result, DateTimeOffset? NextReviewAt); + +public sealed record WordStatsItem( + int Total, + int NewCount, + int LearningCount, + int ReviewingCount, + int MasteredCount, + int DueCount, + int FavoriteCount); + public sealed record PracticeSessionCommand( string? Mode, string? TargetType, diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs index fd3cb54..95ca50b 100644 --- a/Tiku.Infrastructure/Assets/AssetManagementService.cs +++ b/Tiku.Infrastructure/Assets/AssetManagementService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; +using Tiku.Application.Content; using Tiku.Application.Storage; using Tiku.Domain.Common; using Tiku.Domain.Content; @@ -91,6 +92,46 @@ public sealed class AssetManagementService( return new CatalogList(items); } + public async Task> UpsertAssetAsync( + AssetManagementActor actor, + UpsertAssetCommand command, + CancellationToken cancellationToken = default) + { + var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken); + asset.RegionId = command.RegionId; + asset.SubjectId = command.SubjectId; + asset.CategoryId = command.CategoryId; + asset.ContentNodeId = command.ContentNodeId; + asset.LegacyId = NormalizeOptional(command.LegacyId); + asset.AssetKey = NormalizeOptional(command.AssetKey); + asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源"; + asset.Category = NormalizeOptional(command.Category); + asset.Description = NormalizeOptional(command.Description); + asset.FileName = NormalizeOptional(command.FileName); + asset.CdnUrl = NormalizeOptional(command.CdnUrl); + asset.IsPublic = command.IsPublic ?? asset.IsPublic; + asset.AssetType = ParseEnum(command.AssetType, asset.AssetType); + asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic); + asset.Status = ParseEnum(command.Status, asset.Status); + asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider))); + asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim(); + asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey) + ? asset.ObjectKey + : objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim()); + asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim()); + asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes); + asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; + asset.PreviewUrl = NormalizeOptional(command.PreviewUrl); + asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey; + asset.SortOrder = command.Order ?? asset.SortOrder; + asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules; + asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata; + asset.UpdatedBy = actor.UserId; + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToItem(asset)); + } + public async Task SignUploadAsync( AssetManagementActor actor, AssetUploadSignCommand command, @@ -227,6 +268,92 @@ public sealed class AssetManagementService( return new AssetUploadConfirmResult(ToItem(asset), metadata); } + public Task SignDownloadAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + CancellationToken cancellationToken = default) + { + return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken); + } + + public Task SignPreviewAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + CancellationToken cancellationToken = default) + { + return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken); + } + + public async Task> GetAccessEventsAsync( + AssetManagementActor actor, + AssetEventFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentAssetAccessEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (filter.AssetId.HasValue) + { + query = query.Where(item => item.AssetId == filter.AssetId.Value); + } + + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + var items = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new ContentAssetAccessEventItem( + item.Id, + item.AssetId, + item.UserId, + item.ActorRole, + item.AccessType, + item.Visibility, + item.AssetType, + item.StorageProvider, + item.Disposition, + item.ExpiresInSeconds, + item.SignatureMode, + item.Result, + item.DenyCode, + item.IpAddress, + item.UserAgent, + item.Metadata, + item.CreatedAt)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> GetSecurityScanEventsAsync( + AssetManagementActor actor, + AssetEventFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (filter.AssetId.HasValue) + { + query = query.Where(item => item.AssetId == filter.AssetId.Value); + } + + var items = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new ContentAssetSecurityScanEventItem( + item.Id, + item.AssetId, + item.Provider, + item.ScanStatus, + item.RiskLevel, + item.IssueCodes, + item.Details, + item.CreatedAt)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + public async Task> GetImportJobsAsync( AssetManagementActor actor, ImportJobFilter filter, @@ -372,6 +499,107 @@ public sealed class AssetManagementService( return asset; } + private async Task ResolveManagementAssetAsync( + AssetManagementActor actor, + UpsertAssetCommand command, + CancellationToken cancellationToken) + { + ContentAsset? asset = null; + if (command.AssetId.HasValue) + { + asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, + cancellationToken); + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + } + else if (!string.IsNullOrWhiteSpace(command.LegacyId)) + { + var legacyId = command.LegacyId.Trim(); + asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.LegacyId == legacyId, + cancellationToken); + } + + if (asset is not null) + { + return asset; + } + + asset = new ContentAsset + { + Id = command.AssetId ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId, + Source = "manual", + Status = ContentStatus.Active + }; + dbContext.ContentAssets.Add(asset); + return asset; + } + + private async Task SignAssetAccessAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + AssetAccessType accessType, + string disposition, + CancellationToken cancellationToken) + { + var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active, + cancellationToken); + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + + var provider = ToObjectStorageProvider(asset.StorageProvider); + var objectKey = accessType == AssetAccessType.AdminPreview + ? asset.PreviewObjectKey ?? asset.ObjectKey + : asset.ObjectKey; + var cdnUrl = accessType == AssetAccessType.AdminPreview + ? asset.PreviewUrl ?? asset.CdnUrl + : asset.CdnUrl; + var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600)); + var url = await objectStorageService.SignDownloadAsync( + new ObjectStorageDownloadSignRequest( + actor.TenantId, + provider, + asset.Bucket, + objectKey, + expiresIn, + cdnUrl, + asset.FileName, + disposition), + cancellationToken); + dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent + { + TenantId = actor.TenantId, + AssetId = asset.Id, + UserId = actor.UserId, + ActorRole = AssetAccessActorRole.TenantAdmin, + AccessType = accessType, + Visibility = asset.Visibility.ToString(), + AssetType = asset.AssetType.ToString(), + StorageProvider = asset.StorageProvider.ToString(), + Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment, + ExpiresInSeconds = (int)expiresIn.TotalSeconds, + SignatureMode = url.SignatureMode, + Result = AssetAccessResult.Granted, + Metadata = JsonSerializer.SerializeToElement(new + { + url.Provider, + url.Bucket, + url.ObjectKey + }) + }); + await dbContext.SaveChangesAsync(cancellationToken); + return new AssetManagementSignedAccessResult(ToItem(asset), url); + } + private static ContentAssetManagementItem ToItem(ContentAsset asset) { return new ContentAssetManagementItem( @@ -527,6 +755,19 @@ public sealed class AssetManagementService( return isPublic ? ContentVisibility.Public : ContentVisibility.Members; } + private static TEnum ParseEnum(string? value, TEnum fallback) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + return Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed) + ? parsed + : fallback; + } + private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType) { return assetType is ContentAssetType.Pdf or ContentAssetType.Image || diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs new file mode 100644 index 0000000..ba588aa --- /dev/null +++ b/Tiku.Infrastructure/Content/DirectContentService.cs @@ -0,0 +1,1740 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Content; + +public sealed class DirectContentService(TikuDbContext dbContext) : IDirectContentService +{ + private const int DefaultLimit = 100; + private const int MaxLimit = 1000; + private static readonly HashSet SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase) + { + "questions", + "vocabulary", + "handbook", + "scoreline", + "videos" + }; + + public async Task> CreateQuestionAsync( + DirectContentActor actor, + QuestionWriteCommand command, + CancellationToken cancellationToken = default) + { + await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); + + var question = new Question + { + Id = command.QuestionId ?? Guid.NewGuid(), + TenantId = actor.TenantId + }; + ApplyQuestion(question, command); + dbContext.Questions.Add(question); + + var version = BuildQuestionVersion(actor, question.Id, 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToQuestionItem(question, version)); + } + + public async Task> UpdateQuestionAsync( + DirectContentActor actor, + QuestionWriteCommand command, + CancellationToken cancellationToken = default) + { + if (!command.QuestionId.HasValue) + { + throw new ContentManagementException("questionId is required.", "question_id_required"); + } + + var question = await dbContext.Questions.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value, + cancellationToken); + if (question is null) + { + throw new ContentManagementException("Question was not found.", "question_not_found"); + } + + await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); + ApplyQuestion(question, command); + QuestionVersion? version; + if (command.CreateVersion || !question.CurrentVersionId.HasValue) + { + var nextVersionNo = await dbContext.QuestionVersions + .Where(item => item.TenantId == actor.TenantId && item.QuestionId == question.Id) + .Select(item => (int?)item.VersionNo) + .MaxAsync(cancellationToken) ?? 0; + version = BuildQuestionVersion(actor, question.Id, nextVersionNo + 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + } + else + { + version = await dbContext.QuestionVersions.SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.QuestionId == question.Id && + item.Id == question.CurrentVersionId.Value, + cancellationToken); + if (version is null) + { + version = BuildQuestionVersion(actor, question.Id, 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + } + else + { + ApplyQuestionVersion(version, command); + } + } + + await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToQuestionItem(question, version)); + } + + public async Task> GetVocabularyUnitsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VocabularyUnits.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertVocabularyUnitAsync( + DirectContentActor actor, + VocabularyUnitCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.EntryId = command.EntryId; + item.ContentNodeId = command.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.WordCount = command.WordCount; + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.VocabularyUnits.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetVocabularyWordsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.UnitId.HasValue) + { + query = query.Where(item => item.UnitId == filter.UnitId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Word) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertVocabularyWordAsync( + DirectContentActor actor, + VocabularyWordCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Word); + await AssertReferenceAsync(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + var vocabularyNavigation = await ResolveVocabularyNavigationAsync( + actor.TenantId, + command.UnitId, + command.EntryId, + command.ContentNodeId, + cancellationToken); + item.UnitId = command.UnitId; + item.EntryId = vocabularyNavigation.EntryId; + item.ContentNodeId = vocabularyNavigation.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Word = command.Word.Trim(); + item.Phonetic = Normalize(command.Phonetic); + item.Meaning = Normalize(command.Meaning); + item.Example = Normalize(command.Example); + item.ExampleTranslation = Normalize(command.ExampleTranslation); + item.Difficulty = command.Difficulty; + item.Tags = JsonArrayOrDefault(command.Tags); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.VocabularyWords.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetHandbookSubjectsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookSubjects.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + if (filter.MajorId.HasValue) + { + query = query.Where(item => item.MajorId == filter.MajorId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertHandbookSubjectAsync( + DirectContentActor actor, + HandbookSubjectCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.MajorId = command.MajorId; + item.EntryId = command.EntryId; + item.ContentNodeId = command.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Type = ParseNullable(command.Type, "handbook_subject_type_invalid"); + item.Icon = Normalize(command.Icon); + item.Color = Normalize(command.Color); + item.Description = Normalize(command.Description); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.HandbookSubjects.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetHandbookChaptersAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.SubjectId.HasValue) + { + query = query.Where(item => item.SubjectId == filter.SubjectId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertHandbookChapterAsync( + DirectContentActor actor, + HandbookChapterCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + var chapterNavigation = await ResolveHandbookSubjectNavigationAsync( + actor.TenantId, + command.SubjectId, + command.EntryId, + command.ContentNodeId, + cancellationToken); + item.SubjectId = command.SubjectId; + item.EntryId = chapterNavigation.EntryId; + item.ContentNodeId = chapterNavigation.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.HandbookChapters.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetHandbookEntriesAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.ChapterId.HasValue) + { + query = query.Where(item => item.ChapterId == filter.ChapterId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Title) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertHandbookEntryAsync( + DirectContentActor actor, + HandbookEntryCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + await AssertReferenceAsync(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + var entryNavigation = await ResolveHandbookChapterNavigationAsync( + actor.TenantId, + command.ChapterId, + command.EntryId, + command.ContentNodeId, + cancellationToken); + item.ChapterId = command.ChapterId; + item.EntryId = entryNavigation.EntryId; + item.ContentNodeId = entryNavigation.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Title = command.Title.Trim(); + item.Summary = Normalize(command.Summary); + item.Content = Normalize(command.Content); + item.Tags = JsonArrayOrDefault(command.Tags); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.HandbookEntries.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetSchoolsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.Schools.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword)); + } + + return new CatalogList(await query + .OrderBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertSchoolAsync( + DirectContentActor actor, + SchoolCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate); + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.Schools.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetMajorsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.Majors.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertMajorAsync( + DirectContentActor actor, + MajorCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.StudyTips = Normalize(command.StudyTips); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Majors.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetScorelineYearsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ExamDates.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.ExamAt.HasValue); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + var years = await query + .Select(item => item.ExamAt!.Value.Year) + .Distinct() + .OrderByDescending(year => year) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + return new CatalogList(years); + } + + public async Task> GetScorelineTrendAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var years = await GetScorelineYearsAsync(actor, filter, cancellationToken); + var items = new List(); + foreach (var year in years.Items) + { + var schoolCount = await dbContext.ExamDates.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.ExamAt.HasValue && item.ExamAt.Value.Year == year) + .Select(item => item.SchoolId) + .Where(id => id.HasValue) + .Distinct() + .CountAsync(cancellationToken); + var majorCount = await dbContext.Majors.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.IsActive) + .CountAsync(cancellationToken); + items.Add(new ScorelineTrendItem(year, schoolCount, majorCount)); + } + + return new CatalogList(items); + } + + public async Task> GetVideosAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.SubjectId.HasValue) + { + query = query.Where(item => item.SubjectId == filter.SubjectId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + var items = await query + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToVideoItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertVideoAsync( + DirectContentActor actor, + VideoExplanationCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.SubjectId = command.SubjectId; + item.LegacyId = Normalize(command.LegacyId); + item.Title = command.Title.Trim(); + item.Description = Normalize(command.Description); + item.VideoUrl = Normalize(command.VideoUrl); + item.ThumbnailUrl = Normalize(command.ThumbnailUrl); + item.DurationSeconds = command.DurationSeconds; + item.KnowledgeTags = JsonArrayOrDefault(command.KnowledgeTags); + item.IsGeneral = command.IsGeneral ?? item.IsGeneral; + item.Difficulty = command.Difficulty; + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.VideoExplanations.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToVideoItem(item)); + } + + public async Task> BindQuestionVideoAsync( + DirectContentActor actor, + QuestionVideoCommand command, + CancellationToken cancellationToken = default) + { + await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", cancellationToken); + var item = await dbContext.QuestionVideos.SingleOrDefaultAsync( + link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId, + cancellationToken); + var isNew = item is null; + item ??= new QuestionVideo { TenantId = actor.TenantId }; + item.QuestionId = command.QuestionId; + item.VideoId = command.VideoId; + item.LegacyId = Normalize(command.LegacyId); + item.VideoType = Parse(command.VideoType, QuestionVideoType.Specific, "question_video_type_invalid"); + item.SortOrder = command.Order ?? item.SortOrder; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.QuestionVideos.Add(item); + } + + var question = await dbContext.Questions.SingleAsync( + question => question.TenantId == actor.TenantId && question.Id == command.QuestionId, + cancellationToken); + question.HasVideoExplanation = true; + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToQuestionVideoItem(item)); + } + + public async Task> GetOperationContentAsync( + DirectContentActor actor, + string kind, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var items = NormalizeOperationKind(kind) switch + { + "banners" => (await dbContext.Banners.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + "faqs" => (await dbContext.Faqs.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + "announcements" => (await dbContext.Announcements.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + "exam-dates" => (await dbContext.ExamDates.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) + .Where(item => !filter.SchoolId.HasValue || item.SchoolId == filter.SchoolId.Value) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.ExamAt == null) + .ThenBy(item => item.ExamAt) + .ThenBy(item => item.SortOrder) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") + }; + + return new CatalogList(items); + } + + public async Task> UpsertOperationContentAsync( + DirectContentActor actor, + string kind, + OperationContentCommand command, + CancellationToken cancellationToken = default) + { + OperationContentItem item = NormalizeOperationKind(kind) switch + { + "banners" => ToOperationItem(await UpsertBannerAsync(actor, command, cancellationToken)), + "faqs" => ToOperationItem(await UpsertFaqAsync(actor, command, cancellationToken)), + "announcements" => ToOperationItem(await UpsertAnnouncementAsync(actor, command, cancellationToken)), + "exam-dates" => ToOperationItem(await UpsertExamDateAsync(actor, command, cancellationToken)), + _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") + }; + + return new ContentManagementResult(item); + } + + public Task PreviewImportAsync( + DirectContentActor actor, + SimpleImportCommand command, + CancellationToken cancellationToken = default) + { + return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken); + } + + public Task ExecuteImportAsync( + DirectContentActor actor, + SimpleImportCommand command, + CancellationToken cancellationToken = default) + { + return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken); + } + + public async Task> GetImportIssuesAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) + .OrderBy(item => item.RowNo) + .ThenBy(item => item.CreatedAt) + .Take(MaxLimit) + .Select(item => new ContentImportIssueModel( + item.Id, + item.JobId, + item.ItemId, + item.RowNo, + item.Severity, + item.Code, + item.FieldPath, + item.Message, + item.Details)) + .ToArrayAsync(cancellationToken); + return new CatalogList(issues); + } + + public async Task RunImportPostCheckAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == jobId, + cancellationToken); + if (job is null) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + + var counts = JsonSerializer.SerializeToElement(new + { + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount + }); + job.Summary = JsonSerializer.SerializeToElement(new + { + postCheck = new + { + status = job.ErrorCount == 0 ? "passed" : "warning", + checkedAt = DateTimeOffset.UtcNow, + counts + } + }); + await dbContext.SaveChangesAsync(cancellationToken); + return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); + } + + public async Task GetImportPostCheckAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == jobId, + cancellationToken); + if (job is null) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + + var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); + var counts = JsonSerializer.SerializeToElement(new + { + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount + }); + return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); + } + + private async Task CreateImportJobAsync( + DirectContentActor actor, + SimpleImportCommand command, + bool execute, + CancellationToken cancellationToken) + { + if (!SupportedImportTypes.Contains(command.ImportType)) + { + throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); + } + + var importType = ParseImportType(command.ImportType); + var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); + var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); + var job = new ContentImportJob + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + TargetRegionId = command.RegionId, + TargetSubjectId = command.SubjectId, + TargetCategoryId = command.CategoryId, + TargetContentNodeId = command.ContentNodeId, + TargetQuestionBankId = command.QuestionBankId, + ImportType = importType, + SourceFormat = sourceFormat, + Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview, + SourceName = Normalize(command.SourceName), + DryRun = command.DryRun, + TotalCount = items.Length, + ValidCount = items.Length, + RawPayload = JsonSerializer.SerializeToElement(items), + NormalizedPayload = JsonSerializer.SerializeToElement(items), + StartedAt = execute ? DateTimeOffset.UtcNow : null, + FinishedAt = execute ? DateTimeOffset.UtcNow : null + }; + dbContext.ContentImportJobs.Add(job); + + var importItems = new List(); + var rowNo = 1; + foreach (var payload in items) + { + var importItem = new ContentImportItem + { + TenantId = actor.TenantId, + JobId = job.Id, + RowNo = rowNo++, + ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"), + Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid, + SourcePayload = payload, + NormalizedPayload = payload + }; + + if (execute) + { + var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken); + importItem.TargetType = target.TargetType; + importItem.TargetId = target.TargetId; + job.InsertedCount++; + } + + importItems.Add(importItem); + } + + dbContext.ContentImportItems.AddRange(importItems); + job.Summary = JsonSerializer.SerializeToElement(new + { + mode = execute ? "execute" : "preview", + supportedTypes = SupportedImportTypes, + note = "Synchronous direct migration import skeleton; async worker will be introduced later." + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return new SimpleImportResult( + ToJobItem(job), + importItems.Select(ToImportItem).ToArray(), + []); + } + + private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync( + DirectContentActor actor, + SimpleImportCommand command, + JsonElement payload, + CancellationToken cancellationToken) + { + switch (command.ImportType.ToLowerInvariant()) + { + case "questions": + var result = await CreateQuestionAsync(actor, new QuestionWriteCommand( + null, + command.QuestionBankId, + command.SubjectId, + command.CategoryId, + null, + command.EntryId, + command.ContentNodeId, + command.CollectionId, + GetString(payload, "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"), + "Published", + GetElement(payload, "examMarkers", JsonDefaults.Object()), + GetString(payload, "sourceHash"), + true), cancellationToken); + return ("question", result.Item.Id); + case "vocabulary": + var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand( + null, + null, + command.EntryId, + command.ContentNodeId, + GetString(payload, "legacyId"), + GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词", + GetString(payload, "phonetic"), + GetString(payload, "meaning"), + GetString(payload, "example"), + GetString(payload, "exampleTranslation"), + GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("vocabulary_word", word.Item.Id); + case "handbook": + var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand( + null, + null, + command.EntryId, + command.ContentNodeId, + GetString(payload, "legacyId"), + GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目", + GetString(payload, "summary"), + GetString(payload, "content"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("handbook_entry", entry.Item.Id); + case "scoreline": + var school = await UpsertSchoolAsync(actor, new SchoolCommand( + null, + command.RegionId, + GetString(payload, "legacyId"), + GetString(payload, "schoolName") ?? GetString(payload, "name") ?? "未命名学校", + GetString(payload, "professionalExamDate"), + payload), cancellationToken); + return ("school", school.Item.Id); + case "videos": + var video = await UpsertVideoAsync(actor, new VideoExplanationCommand( + null, + command.SubjectId, + GetString(payload, "legacyId"), + GetString(payload, "title") ?? "未命名视频", + GetString(payload, "description"), + GetString(payload, "videoUrl") ?? GetString(payload, "url"), + GetString(payload, "thumbnailUrl"), + GetInt(payload, "durationSeconds"), + GetElement(payload, "knowledgeTags", JsonDefaults.Array()), + GetBool(payload, "isGeneral"), + GetInt(payload, "difficulty"), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("video_explanation", video.Item.Id); + default: + throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); + } + } + + private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Title = Normalize(command.Title); + item.Subtitle = Normalize(command.Subtitle); + item.Content = Normalize(command.Content); + item.ButtonText = Normalize(command.ButtonText); + item.ButtonLink = Normalize(command.ButtonLink); + item.BackgroundColor = Normalize(command.BackgroundColor); + item.BorderColor = Normalize(command.BorderColor); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Banners.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Question = Normalize(command.Question) ?? Normalize(command.Title); + item.Answer = Normalize(command.Answer) ?? Normalize(command.Content); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Faqs.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.LegacyId = Normalize(command.LegacyId); + item.Content = Normalize(command.Content) ?? Normalize(command.Title); + item.Link = Normalize(command.Link); + item.BackgroundColor = Normalize(command.BackgroundColor); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Announcements.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.LegacyId = Normalize(command.LegacyId); + item.ExamName = command.ExamName.Trim(); + item.ExamAt = command.ExamAt; + item.ExamType = Normalize(command.ExamType); + item.Description = Normalize(command.Description) ?? Normalize(command.Content); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.ExamDates.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private static void ApplyQuestion(Question question, QuestionWriteCommand command) + { + question.QuestionBankId = command.QuestionBankId; + question.SubjectId = command.SubjectId; + question.CategoryId = command.CategoryId; + question.NodeId = command.NodeId; + question.EntryId = command.EntryId; + question.ContentNodeId = command.ContentNodeId; + question.PrimaryCollectionId = command.PrimaryCollectionId; + question.LegacyId = Normalize(command.LegacyId); + question.Type = Normalize(command.Type) ?? question.Type; + question.TypeLabel = Normalize(command.TypeLabel); + question.Difficulty = command.Difficulty; + question.Tags = JsonArrayOrDefault(command.Tags); + question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers); + question.MediaUrl = Normalize(command.MediaUrl); + question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); + } + + private static QuestionVersion BuildQuestionVersion( + DirectContentActor actor, + Guid questionId, + int versionNo, + QuestionWriteCommand command) + { + var version = new QuestionVersion + { + TenantId = actor.TenantId, + QuestionId = questionId, + VersionNo = versionNo, + CreatedBy = actor.UserId + }; + ApplyQuestionVersion(version, command); + return version; + } + + private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command) + { + version.Content = Normalize(command.Content); + version.Options = JsonArrayOrDefault(command.Options); + version.CorrectOptionIndex = command.CorrectOptionIndex; + version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices); + version.AnswerText = Normalize(command.AnswerText); + version.Explanation = Normalize(command.Explanation); + version.SubQuestions = JsonArrayOrDefault(command.SubQuestions); + version.CodeLang = Normalize(command.CodeLang); + version.CodeTemplate = Normalize(command.CodeTemplate); + version.SourceHash = Normalize(command.SourceHash); + } + + private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.SubjectId, "subject_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.CategoryId, "category_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.NodeId, "module_node_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken); + } + + private async Task SyncPrimaryCollectionItemAsync( + DirectContentActor actor, + Question question, + CancellationToken cancellationToken) + { + if (!question.PrimaryCollectionId.HasValue) + { + return; + } + + var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.CollectionId == question.PrimaryCollectionId.Value && + item.QuestionId == question.Id, + cancellationToken); + if (existing is null) + { + var nextOrder = await dbContext.QuestionCollectionItems + .Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value) + .Select(item => (int?)item.SortOrder) + .MaxAsync(cancellationToken) ?? -1; + dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem + { + TenantId = actor.TenantId, + CollectionId = question.PrimaryCollectionId.Value, + QuestionId = question.Id, + SortOrder = nextOrder + 1 + }); + } + + var collection = await dbContext.QuestionCollections.SingleAsync( + item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value, + cancellationToken); + collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync( + item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value, + cancellationToken) + (existing is null ? 1 : 0); + collection.UpdatedBy = actor.UserId; + } + + private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync( + Guid tenantId, + Guid? unitId, + Guid? entryId, + Guid? contentNodeId, + CancellationToken cancellationToken) + { + if (!unitId.HasValue) + { + return (entryId, contentNodeId); + } + + var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == unitId.Value, + cancellationToken); + if (unit is null) + { + throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found"); + } + + return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId); + } + + private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync( + Guid tenantId, + Guid? subjectId, + Guid? entryId, + Guid? contentNodeId, + CancellationToken cancellationToken) + { + if (!subjectId.HasValue) + { + return (entryId, contentNodeId); + } + + var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == subjectId.Value, + cancellationToken); + if (subject is null) + { + throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found"); + } + + return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId); + } + + private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync( + Guid tenantId, + Guid? chapterId, + Guid? entryId, + Guid? contentNodeId, + CancellationToken cancellationToken) + { + if (!chapterId.HasValue) + { + return (entryId, contentNodeId); + } + + var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == chapterId.Value, + cancellationToken); + if (chapter is null) + { + throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found"); + } + + return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId); + } + + private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version) + { + return new QuestionManagementItem( + question.Id, + version?.Id, + question.QuestionBankId, + question.SubjectId, + question.CategoryId, + question.NodeId, + question.EntryId, + question.ContentNodeId, + question.PrimaryCollectionId, + question.LegacyId, + question.Type, + question.TypeLabel, + question.Difficulty, + question.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, + question.MediaUrl, + question.HasVideoExplanation, + question.Status); + } + + private static VideoManagementItem ToVideoItem(VideoExplanation item) + { + return new VideoManagementItem( + item.Id, + item.SubjectId, + item.LegacyId, + item.Title, + item.Description, + item.VideoUrl, + item.ThumbnailUrl, + item.DurationSeconds, + item.KnowledgeTags, + item.IsGeneral, + item.Difficulty, + item.SortOrder, + item.IsActive, + item.Metadata); + } + + private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item) + { + return new QuestionVideoManagementItem( + item.Id, + item.QuestionId, + item.VideoId, + item.LegacyId, + item.VideoType, + item.SortOrder, + item.Metadata); + } + + private static OperationContentItem ToOperationItem(Banner item) + { + return new OperationContentItem( + item.Id, + "banners", + item.RegionId, + null, + item.LegacyId, + item.Title, + item.Content, + null, + null, + null, + null, + item.SortOrder, + item.IsActive, + JsonSerializer.SerializeToElement(new + { + item.Subtitle, + item.ButtonText, + item.ButtonLink, + item.BackgroundColor, + item.BorderColor + })); + } + + private static OperationContentItem ToOperationItem(Faq item) + { + return new OperationContentItem( + item.Id, + "faqs", + item.RegionId, + null, + item.LegacyId, + null, + null, + item.Question, + item.Answer, + null, + null, + item.SortOrder, + item.IsActive, + JsonDefaults.Object()); + } + + private static OperationContentItem ToOperationItem(Announcement item) + { + return new OperationContentItem( + item.Id, + "announcements", + null, + null, + item.LegacyId, + null, + item.Content, + null, + null, + null, + null, + item.SortOrder, + item.IsActive, + JsonSerializer.SerializeToElement(new + { + item.Link, + item.BackgroundColor + })); + } + + private static OperationContentItem ToOperationItem(ExamDate item) + { + return new OperationContentItem( + item.Id, + "exam-dates", + item.RegionId, + item.SchoolId, + item.LegacyId, + item.ExamName, + item.Description, + null, + null, + item.ExamAt, + item.ExamType, + item.SortOrder, + item.IsActive, + item.Metadata); + } + + private static ContentImportJobItem ToJobItem(ContentImportJob job) + { + return new ContentImportJobItem( + job.Id, + job.TargetRegionId, + job.TargetSubjectId, + job.TargetCategoryId, + job.TargetContentNodeId, + job.TargetQuestionBankId, + job.ImportType, + job.SourceFormat, + job.Status, + job.SourceName, + job.SourceHash, + job.DryRun, + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount, + job.Summary, + job.ErrorMessage, + job.StartedAt, + job.FinishedAt, + job.CreatedAt, + job.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 async Task ResolveByIdOrLegacyAsync( + DbSet set, + Guid tenantId, + Guid? id, + string? legacyId, + CancellationToken cancellationToken) + where TEntity : AuditableTenantEntity + { + if (id.HasValue) + { + return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken); + } + + var normalizedLegacyId = Normalize(legacyId); + return normalizedLegacyId is null + ? null + : await set.SingleOrDefaultAsync( + item => item.TenantId == tenantId && EF.Property(item, "LegacyId") == normalizedLegacyId, + cancellationToken); + } + + private async Task AssertReferenceAsync( + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var exists = await dbContext.Set() + .AnyAsync(item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); + if (!exists) + { + throw new ContentManagementException("Referenced entity was not found.", code); + } + } + + private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) + { + var exists = await dbContext.ContentImportJobs.AnyAsync( + item => item.TenantId == tenantId && item.Id == jobId, + cancellationToken); + if (!exists) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + } + + private static string NormalizeOperationKind(string kind) + { + var normalized = Normalize(kind)?.ToLowerInvariant(); + return normalized switch + { + "banner" or "banners" => "banners", + "faq" or "faqs" => "faqs", + "announcement" or "announcements" => "announcements", + "exam-date" or "exam-dates" or "examdates" => "exam-dates", + _ => normalized ?? string.Empty + }; + } + + private static ContentImportType ParseImportType(string value) + { + return value.ToLowerInvariant() switch + { + "questions" => ContentImportType.Questions, + "vocabulary" => ContentImportType.Vocabulary, + "handbook" => ContentImportType.Handbook, + "scoreline" => ContentImportType.Scoreline, + "videos" => ContentImportType.Videos, + _ => throw new ContentManagementException("Import type is invalid.", "import_type_invalid") + }; + } + + private static TEnum Parse(string? value, TEnum fallback, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Enum value is invalid.", code); + } + + private static TEnum? ParseNullable(string? value, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Enum value is invalid.", code); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static int ResolveLimit(int? limit) + { + return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit); + } + + private static JsonElement JsonObjectOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object(); + } + + private static JsonElement JsonArrayOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array(); + } + + private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback) + { + return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) + ? value + : fallback; + } + + private static string? GetString(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString(); + } + + private static int? GetInt(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) + ? number + : int.TryParse(value.ToString(), out number) + ? number + : null; + } + + private static bool? GetBool(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index f9c4e2e..5f11a11 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -45,6 +45,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs index b59e29a..49f4a68 100644 --- a/Tiku.Infrastructure/Learning/LearningActivityService.cs +++ b/Tiku.Infrastructure/Learning/LearningActivityService.cs @@ -14,6 +14,100 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning private const int DefaultLimit = 100; private const int MaxLimit = 500; + public async Task GetStatsAsync( + LearningActor actor, + CancellationToken cancellationToken = default) + { + var answers = dbContext.AnswerRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + return new LearningStatsItem( + await answers.CountAsync(cancellationToken), + await answers.CountAsync(item => item.IsCorrect == true, cancellationToken), + await dbContext.WrongQuestions.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null, + cancellationToken), + await dbContext.FavoriteQuestions.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.UserWordFavorites.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.UserWordProgress.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.PracticeSessions.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.PracticeSessionReports.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken)); + } + + public async Task> GetTrendAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit)); + var rows = await dbContext.AnswerRecords.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.AnsweredAt >= since) + .Select(item => new { item.AnsweredAt, item.IsCorrect }) + .ToArrayAsync(cancellationToken); + var items = rows + .GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime)) + .OrderBy(group => group.Key) + .Select(group => new LearningTrendItem( + group.Key, + group.Count(), + group.Count(item => item.IsCorrect == true), + group.Count(item => item.IsCorrect == false))) + .ToArray(); + return new LearningList(items); + } + + public async Task GetLeaderboardAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var rows = await dbContext.AnswerRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .GroupBy(item => item.UserId) + .Select(group => new + { + UserId = group.Key, + AnswerCount = group.Count(), + CorrectCount = group.Count(item => item.IsCorrect == true), + WrongCount = group.Count(item => item.IsCorrect == false) + }) + .OrderByDescending(item => item.CorrectCount) + .ThenByDescending(item => item.AnswerCount) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = rows.Select(item => item.UserId).ToArray(); + var names = await dbContext.Users.AsNoTracking() + .Where(item => userIds.Contains(item.Id)) + .ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken); + var items = rows + .Select(item => new LearningLeaderboardItem( + item.UserId, + names.GetValueOrDefault(item.UserId), + item.AnswerCount, + item.CorrectCount, + item.WrongCount, + item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4))) + .ToArray(); + return new LearningLeaderboardResult( + "correct_count", + "all", + items, + items.FirstOrDefault(item => item.UserId == actor.UserId), + DateTimeOffset.UtcNow); + } + public async Task SubmitAnswerAsync( LearningActor actor, SubmitAnswerCommand command, @@ -201,6 +295,31 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning return new LearningActionResult(true); } + public async Task GetWrongQuestionReviewPlanAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var items = await dbContext.WrongQuestions.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.ResolvedAt == null) + .OrderByDescending(item => item.WrongCount) + .ThenBy(item => item.LastWrongAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt)) + .ToArrayAsync(cancellationToken); + return new WrongQuestionReviewPlan( + items, + JsonSerializer.SerializeToElement(new + { + mode = "wrong_review", + questionCount = items.Length, + recommendedEndpoint = "/api/learning/practice-sessions" + })); + } + public async Task> GetWordProgressAsync( LearningActor actor, LearningLimitFilter filter, @@ -312,6 +431,91 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning return ToItem(item); } + public async Task GetWordReviewPlanAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var query = dbContext.UserWordProgress.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (filter.UnitId.HasValue) + { + query = query.Where(item => dbContext.VocabularyWords.Any(word => + word.TenantId == actor.TenantId && + word.Id == item.WordId && + word.UnitId == filter.UnitId.Value)); + } + + var items = await query + .Where(item => item.NextReviewAt == null || item.NextReviewAt <= now) + .OrderBy(item => item.NextReviewAt == null) + .ThenBy(item => item.NextReviewAt) + .ThenByDescending(item => item.WrongCount) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new WordReviewPlanItem( + item.WordId, + item.Status, + item.NextReviewAt, + item.CorrectCount, + item.WrongCount, + item.DueLevel)) + .ToArrayAsync(cancellationToken); + return new WordReviewPlan( + items, + JsonSerializer.SerializeToElement(new + { + mode = "word_review", + wordCount = items.Length + })); + } + + public Task ReviewWordAsync( + LearningActor actor, + WordReviewCommand command, + CancellationToken cancellationToken = default) + { + var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) || + string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase); + return UpdateWordProgressAsync( + actor, + new WordProgressCommand( + command.WordId, + correct ? "Reviewing" : "Learning", + correct ? 1 : 0, + correct ? 0 : 1, + command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)), + cancellationToken); + } + + public async Task GetWordStatsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var query = dbContext.UserWordProgress.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (filter.UnitId.HasValue) + { + query = query.Where(item => dbContext.VocabularyWords.Any(word => + word.TenantId == actor.TenantId && + word.Id == item.WordId && + word.UnitId == filter.UnitId.Value)); + } + + return new WordStatsItem( + await query.CountAsync(cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken), + await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken), + await dbContext.UserWordFavorites.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken)); + } + public async Task> GetFavoriteWordsAsync( LearningActor actor, LearningLimitFilter filter, diff --git a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs index be7ca2d..286acfc 100644 --- a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs @@ -198,6 +198,58 @@ public sealed class AssetManagementEndpointTests Assert.Single(detail.RootElement.GetProperty("issues").EnumerateArray()); } + [Fact] + public async Task Tenant_admin_can_upsert_asset_sign_access_and_query_asset_events() + { + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var upsertResponse = await client.PutAsJsonAsync( + "/api/tenant-content/assets", + new UpsertAssetDto + { + Title = "管理侧资料", + FileName = "admin.pdf", + AssetType = "pdf", + Visibility = "members", + Provider = "local_dev", + Bucket = "tenant-assets", + ObjectKey = $"{seed.TenantId:N}/admin.pdf", + PreviewObjectKey = $"{seed.TenantId:N}/admin-preview.pdf", + MimeType = "application/pdf", + FileSizeBytes = 512 + }); + var upsert = await ReadJsonAsync(upsertResponse); + var assetId = upsert.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + await factory.SeedAsync(new ContentAssetSecurityScanEvent + { + TenantId = seed.TenantId, + AssetId = assetId, + Provider = "local", + ScanStatus = AssetSecurityScanStatus.Passed, + RiskLevel = AssetSecurityRiskLevel.None + }); + + var downloadResponse = await client.PostAsJsonAsync( + "/api/tenant-content/assets/sign-download", + new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 }); + var previewResponse = await client.PostAsJsonAsync( + "/api/tenant-content/assets/sign-preview", + new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 }); + var accessEventsResponse = await client.GetAsync($"/api/tenant-content/assets/access-events?assetId={assetId}"); + var scanEventsResponse = await client.GetAsync($"/api/tenant-content/assets/security-scan-events?assetId={assetId}"); + var accessEvents = await ReadJsonAsync(accessEventsResponse); + var scanEvents = await ReadJsonAsync(scanEventsResponse); + + Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, downloadResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + Assert.Equal(2, accessEvents.RootElement.GetProperty("items").GetArrayLength()); + Assert.Single(scanEvents.RootElement.GetProperty("items").EnumerateArray()); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); diff --git a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs new file mode 100644 index 0000000..ad65161 --- /dev/null +++ b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs @@ -0,0 +1,250 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Identity; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class DirectContentEndpointTests +{ + [Fact] + public async Task Tenant_admin_can_create_question_and_sync_primary_collection() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var collectionId = Guid.NewGuid(); + await factory.SeedAsync(new QuestionCollection + { + Id = collectionId, + TenantId = seed.TenantId, + Name = "直接迁移题集", + CollectionType = QuestionCollectionType.Manual, + SourceType = QuestionCollectionSourceType.ManualQuestions + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var response = await client.PostAsJsonAsync( + "/api/tenant-content/questions", + new DirectQuestionWriteDto + { + PrimaryCollectionId = collectionId, + Type = "choice", + Content = "1 + 1 = ?", + Options = JsonSerializer.SerializeToElement(new[] { "1", "2" }), + CorrectOptionIndex = 1, + Tags = JsonSerializer.SerializeToElement(new[] { "math" }) + }); + var json = await ReadJsonAsync(response); + var questionId = json.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(collectionId, json.RootElement.GetProperty("item").GetProperty("primaryCollectionId").GetGuid()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Contains(dbContext.QuestionCollectionItems, item => item.CollectionId == collectionId && item.QuestionId == questionId); + Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount); + } + + [Fact] + public async Task Tenant_admin_can_upsert_vocabulary_handbook_video_and_operations_content() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var unitResponse = await client.PutAsJsonAsync( + "/api/tenant-content/vocabulary-units", + new DirectVocabularyUnitDto { Name = "Unit 1", WordCount = 1 }); + var unitJson = await ReadJsonAsync(unitResponse); + var unitId = unitJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var wordResponse = await client.PutAsJsonAsync( + "/api/tenant-content/vocabulary-words", + new DirectVocabularyWordDto { UnitId = unitId, Word = "scale", Meaning = "规模" }); + + var subjectResponse = await client.PutAsJsonAsync( + "/api/tenant-content/handbook-subjects", + new DirectHandbookSubjectDto { Name = "文化常识", Type = "Common" }); + var subjectJson = await ReadJsonAsync(subjectResponse); + var subjectId = subjectJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var chapterResponse = await client.PutAsJsonAsync( + "/api/tenant-content/handbook-chapters", + new DirectHandbookChapterDto { SubjectId = subjectId, Name = "第一章" }); + var chapterJson = await ReadJsonAsync(chapterResponse); + var chapterId = chapterJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var entryResponse = await client.PutAsJsonAsync( + "/api/tenant-content/handbook-entries", + new DirectHandbookEntryDto { ChapterId = chapterId, Title = "知识点", Content = "正文" }); + + var videoResponse = await client.PutAsJsonAsync( + "/api/tenant-content/videos", + new DirectVideoDto { Title = "解析视频", VideoUrl = "https://example.test/video.mp4" }); + + var bannerResponse = await client.PutAsJsonAsync( + "/api/tenant-content/operations/banners", + new DirectOperationContentDto { Title = "开屏", Content = "欢迎", IsActive = true }); + using var bannerListResponse = await client.GetAsync("/api/tenant-content/operations/banners"); + var bannerListJson = await ReadJsonAsync(bannerListResponse); + + Assert.Equal(HttpStatusCode.OK, unitResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, wordResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, subjectResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, chapterResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, videoResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, bannerResponse.StatusCode); + Assert.Single(bannerListJson.RootElement.GetProperty("items").EnumerateArray()); + } + + [Fact] + public async Task Tenant_admin_can_bind_question_video_and_run_import_skeleton() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var questionResponse = await client.PostAsJsonAsync( + "/api/tenant-content/questions", + new DirectQuestionWriteDto { Type = "choice", Content = "题目" }); + var questionJson = await ReadJsonAsync(questionResponse); + var questionId = questionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var videoResponse = await client.PutAsJsonAsync( + "/api/tenant-content/videos", + new DirectVideoDto { Title = "视频" }); + var videoJson = await ReadJsonAsync(videoResponse); + var videoId = videoJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var bindResponse = await client.PostAsJsonAsync( + "/api/tenant-content/question-videos", + new DirectQuestionVideoDto { QuestionId = questionId, VideoId = videoId }); + + var previewResponse = await client.PostAsJsonAsync( + "/api/tenant-content/imports/preview/questions", + new DirectImportDto + { + Items = + [ + JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题" }) + ] + }); + var previewJson = await ReadJsonAsync(previewResponse); + var previewJobId = previewJson.RootElement.GetProperty("job").GetProperty("id").GetGuid(); + + var executeResponse = await client.PostAsJsonAsync( + "/api/tenant-content/imports/questions", + new DirectImportDto + { + Items = + [ + JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题" }) + ] + }); + var executeJson = await ReadJsonAsync(executeResponse); + var executeJobId = executeJson.RootElement.GetProperty("job").GetProperty("id").GetGuid(); + + var postCheckResponse = await client.PostAsJsonAsync( + "/api/tenant-content/imports/post-check", + new DirectImportJobDto { JobId = executeJobId }); + + Assert.Equal(HttpStatusCode.OK, bindResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, postCheckResponse.StatusCode); + Assert.NotEqual(Guid.Empty, previewJobId); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.True(dbContext.ContentImportJobs.Any(item => item.Id == executeJobId && item.InsertedCount == 1)); + Assert.True(dbContext.Questions.Count(item => item.TenantId == seed.TenantId) >= 2); + Assert.True(dbContext.Questions.Single(item => item.Id == questionId).HasVideoExplanation); + } + + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = $"137{Random.Shared.Next(10000000, 99999999)}"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Test Tenant", + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }, + new User + { + Id = userId, + Phone = phone, + Name = "Tenant Admin" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return (tenantId, userId, phone); + } + + private static async Task LoginAsync( + HttpClient client, + (Guid TenantId, Guid UserId, string Phone) seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } +} diff --git a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs index f5652b9..e8da415 100644 --- a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs @@ -297,6 +297,78 @@ public sealed class LearningEndpointTests Assert.Equal("重点", item.GetProperty("note").GetString()); } + [Fact] + public async Task Learning_stats_plans_leaderboard_and_word_review_are_available() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLearningUserAsync(factory); + var questionId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await factory.SeedAsync( + new Question + { + Id = questionId, + TenantId = seed.TenantId, + Type = "choice", + Status = QuestionStatus.Published + }, + new VocabularyWord + { + Id = wordId, + TenantId = seed.TenantId, + Word = "review", + IsActive = true + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + await client.PostAsJsonAsync( + "/api/learning/answers", + new SubmitAnswerDto + { + QuestionId = questionId, + SelectedOptions = ["A"], + SelfJudgedCorrect = false + }); + await client.PostAsJsonAsync( + "/api/learning/vocabulary/progress", + new WordProgressDto + { + WordId = wordId, + Status = "learning", + WrongDelta = 1, + NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }); + + var statsResponse = await client.GetAsync("/api/learning/stats"); + var trendResponse = await client.GetAsync("/api/learning/trend?limit=7"); + var leaderboardResponse = await client.GetAsync("/api/learning/leaderboard?limit=10"); + var wrongPlanResponse = await client.GetAsync("/api/learning/wrong-questions/review-plan"); + var wordPlanResponse = await client.GetAsync("/api/learning/vocabulary/review-plan"); + var reviewResponse = await client.PostAsJsonAsync( + "/api/learning/vocabulary/review", + new WordReviewDto { WordId = wordId, Result = "correct" }); + var wordStatsResponse = await client.GetAsync("/api/learning/vocabulary/stats"); + + var stats = await ReadJsonAsync(statsResponse); + var trend = await ReadItemsAsync(trendResponse); + var leaderboard = await ReadJsonAsync(leaderboardResponse); + var wrongPlan = await ReadJsonAsync(wrongPlanResponse); + var wordPlan = await ReadJsonAsync(wordPlanResponse); + var review = await ReadJsonAsync(reviewResponse); + var wordStats = await ReadJsonAsync(wordStatsResponse); + + Assert.Equal(HttpStatusCode.OK, statsResponse.StatusCode); + Assert.Equal(1, stats.RootElement.GetProperty("answerCount").GetInt32()); + Assert.Equal(1, stats.RootElement.GetProperty("wrongCount").GetInt32()); + Assert.NotEmpty(trend); + Assert.Equal(seed.UserId, Assert.Single(leaderboard.RootElement.GetProperty("items").EnumerateArray()).GetProperty("userId").GetGuid()); + Assert.Single(wrongPlan.RootElement.GetProperty("items").EnumerateArray()); + Assert.Single(wordPlan.RootElement.GetProperty("items").EnumerateArray()); + Assert.Equal(1, review.RootElement.GetProperty("correctCount").GetInt32()); + Assert.Equal(1, wordStats.RootElement.GetProperty("total").GetInt32()); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync( ApiTestFactory factory) {