diff --git a/Tiku.Api/Contracts/AssetDtos.cs b/Tiku.Api/Contracts/AssetDtos.cs new file mode 100644 index 0000000..3326bf1 --- /dev/null +++ b/Tiku.Api/Contracts/AssetDtos.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Tiku.Application.Assets; + +namespace Tiku.Api.Contracts; + +public sealed class AssetQueryDto +{ + [StringLength(100)] + public string? TenantCode { get; set; } + + public Guid? RegionId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? CategoryId { get; set; } + + public Guid? ContentNodeId { get; set; } + + public Guid? QuestionId { get; set; } + + public Guid? AssetId { get; set; } + + [StringLength(50)] + public string? AssetType { get; set; } + + [StringLength(100)] + public string? Category { get; set; } + + [StringLength(200)] + public string? AssetKey { get; set; } + + [StringLength(100)] + public string? Keyword { get; set; } + + public bool IncludeLocked { get; set; } + + public bool IncludeInactive { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public AssetFilter ToFilter(Guid tenantId) + { + return new AssetFilter( + tenantId, + RegionId, + SubjectId, + CategoryId, + ContentNodeId, + QuestionId, + AssetId, + AssetType, + Category, + AssetKey, + Keyword, + IncludeLocked, + IncludeInactive, + Limit); + } +} diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs index 26e7f69..98de0ff 100644 --- a/Tiku.Api/Controllers/CatalogController.cs +++ b/Tiku.Api/Controllers/CatalogController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; using Tiku.Api.Contracts; using Tiku.Application.Catalog; using Tiku.Application.Content; @@ -21,6 +22,7 @@ public sealed class CatalogController( IContentNavigationQueryService contentNavigationQueryService, IQuestionBankQueryService questionBankQueryService, IStudyContentQueryService studyContentQueryService, + IAssetQueryService assetQueryService, ICurrentTenant currentTenant, TikuDbContext dbContext) : ControllerBase { @@ -303,6 +305,71 @@ public sealed class CatalogController( cancellationToken)); } + [HttpGet("content-assets")] + [EndpointSummary("查询内容资源")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetContentAssets( + [FromQuery] AssetQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetQueryService.GetContentAssetsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("images")] + [EndpointSummary("查询图片资源")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetImages( + [FromQuery] AssetQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetQueryService.GetImagesAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("app-assets")] + [EndpointSummary("查询应用资源")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetAppAssets( + [FromQuery] AssetQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetQueryService.GetAppAssetsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("video-explanations")] + [EndpointSummary("查询视频讲解")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetVideoExplanations( + [FromQuery] AssetQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetQueryService.GetVideoExplanationsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("question-videos")] + [EndpointSummary("查询题目关联视频")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetQuestionVideos( + [FromQuery] AssetQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetQueryService.GetQuestionVideosAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + private async Task ResolveTenantIdAsync( CatalogQueryDto query, CancellationToken cancellationToken) @@ -363,6 +430,18 @@ public sealed class CatalogController( }, cancellationToken); } + + private Task ResolveTenantIdAsync( + AssetQueryDto query, + CancellationToken cancellationToken) + { + return ResolveTenantIdAsync( + new CatalogQueryDto + { + TenantCode = query.TenantCode + }, + cancellationToken); + } } public sealed class TenantNotFoundException : Exception diff --git a/Tiku.Application/Assets/AssetQueryModels.cs b/Tiku.Application/Assets/AssetQueryModels.cs new file mode 100644 index 0000000..3dd58e0 --- /dev/null +++ b/Tiku.Application/Assets/AssetQueryModels.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using Tiku.Domain.Content; + +namespace Tiku.Application.Assets; + +public sealed record AssetFilter( + Guid TenantId, + Guid? RegionId = null, + Guid? SubjectId = null, + Guid? CategoryId = null, + Guid? ContentNodeId = null, + Guid? QuestionId = null, + Guid? AssetId = null, + string? AssetType = null, + string? Category = null, + string? AssetKey = null, + string? Keyword = null, + bool IncludeLocked = false, + bool IncludeInactive = false, + int? Limit = null); + +public sealed record ContentAssetCatalogItem( + Guid Id, + string? LegacyId, + Guid? RegionId, + Guid? SubjectId, + Guid? CategoryId, + Guid? ContentNodeId, + string? AssetKey, + string? Title, + string? Category, + string? Description, + string? FileName, + string? CdnUrl, + bool IsPublic, + ContentAssetType AssetType, + AssetStorageProvider StorageProvider, + string? Bucket, + string? ObjectKey, + string? MimeType, + long? FileSizeBytes, + string? ChecksumSha256, + ContentVisibility Visibility, + string? PreviewUrl, + ContentStatus Status, + int Order, + AssetUploadStatus UploadStatus, + DateTimeOffset? VerifiedAt, + AssetPreviewStatus PreviewStatus, + AssetSecurityScanStatus SecurityScanStatus, + JsonElement Metadata); + +public sealed record ImageAssetCatalogItem( + Guid Id, + string? LegacyId, + string? Title, + string? Category, + string? FileName, + bool IsPublic, + string? CdnUrl, + JsonElement Metadata); + +public sealed record AppAssetCatalogItem( + Guid Id, + string? LegacyId, + string AssetKey, + string? FileName, + string? Description, + JsonElement Metadata); + +public sealed record VideoExplanationCatalogItem( + Guid Id, + string? LegacyId, + Guid? SubjectId, + 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 QuestionVideoCatalogItem( + Guid Id, + string? LegacyId, + Guid? QuestionId, + Guid? VideoId, + QuestionVideoType VideoType, + int Order, + JsonElement Metadata, + VideoExplanationCatalogItem? Video); diff --git a/Tiku.Application/Assets/IAssetQueryService.cs b/Tiku.Application/Assets/IAssetQueryService.cs new file mode 100644 index 0000000..52c73d9 --- /dev/null +++ b/Tiku.Application/Assets/IAssetQueryService.cs @@ -0,0 +1,26 @@ +using Tiku.Application.Catalog; + +namespace Tiku.Application.Assets; + +public interface IAssetQueryService +{ + Task> GetContentAssetsAsync( + AssetFilter filter, + CancellationToken cancellationToken = default); + + Task> GetImagesAsync( + AssetFilter filter, + CancellationToken cancellationToken = default); + + Task> GetAppAssetsAsync( + AssetFilter filter, + CancellationToken cancellationToken = default); + + Task> GetVideoExplanationsAsync( + AssetFilter filter, + CancellationToken cancellationToken = default); + + Task> GetQuestionVideosAsync( + AssetFilter filter, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Assets/AssetQueryService.cs b/Tiku.Infrastructure/Assets/AssetQueryService.cs new file mode 100644 index 0000000..4f35b43 --- /dev/null +++ b/Tiku.Infrastructure/Assets/AssetQueryService.cs @@ -0,0 +1,323 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Domain.Content; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryService +{ + private const int DefaultLimit = 100; + private const int MaxLimit = 500; + + public async Task> GetContentAssetsAsync( + AssetFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentAssets + .AsNoTracking() + .Where(asset => asset.TenantId == filter.TenantId); + + if (!filter.IncludeInactive) + { + query = query.Where(asset => asset.Status == ContentStatus.Active); + } + + if (!filter.IncludeLocked) + { + query = query.Where(asset => asset.Visibility == ContentVisibility.Public || asset.IsPublic); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(asset => asset.RegionId == filter.RegionId.Value || asset.RegionId == null); + } + + if (filter.SubjectId.HasValue) + { + query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value || asset.SubjectId == null); + } + + if (filter.CategoryId.HasValue) + { + query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value || asset.CategoryId == null); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value || asset.ContentNodeId == null); + } + + if (filter.AssetId.HasValue) + { + query = query.Where(asset => asset.Id == filter.AssetId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.AssetType) && + Enum.TryParse(filter.AssetType, ignoreCase: true, out var assetType)) + { + query = query.Where(asset => asset.AssetType == assetType); + } + + if (!string.IsNullOrWhiteSpace(filter.Category)) + { + var category = filter.Category.Trim(); + query = query.Where(asset => asset.Category == category); + } + + if (!string.IsNullOrWhiteSpace(filter.AssetKey)) + { + var assetKey = filter.AssetKey.Trim(); + query = query.Where(asset => asset.AssetKey == assetKey); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(asset => + (asset.Title != null && asset.Title.Contains(keyword)) || + (asset.FileName != null && asset.FileName.Contains(keyword)) || + (asset.Description != null && asset.Description.Contains(keyword)) || + (asset.AssetKey != null && asset.AssetKey.Contains(keyword))); + } + + var items = await query + .OrderBy(asset => asset.SortOrder) + .ThenByDescending(asset => asset.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(asset => new ContentAssetCatalogItem( + asset.Id, + asset.LegacyId, + asset.RegionId, + asset.SubjectId, + asset.CategoryId, + asset.ContentNodeId, + asset.AssetKey, + asset.Title, + asset.Category, + asset.Description, + asset.FileName, + asset.CdnUrl, + asset.IsPublic, + asset.AssetType, + asset.StorageProvider, + asset.Bucket, + asset.ObjectKey, + asset.MimeType, + asset.FileSizeBytes, + asset.ChecksumSha256, + asset.Visibility, + asset.PreviewUrl, + asset.Status, + asset.SortOrder, + asset.UploadStatus, + asset.VerifiedAt, + asset.PreviewStatus, + asset.SecurityScanStatus, + asset.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetImagesAsync( + AssetFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.Images + .AsNoTracking() + .Where(image => image.TenantId == filter.TenantId); + + if (!filter.IncludeLocked) + { + query = query.Where(image => image.IsPublic); + } + + if (!string.IsNullOrWhiteSpace(filter.Category)) + { + var category = filter.Category.Trim(); + query = query.Where(image => image.Category == category); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(image => + (image.Title != null && image.Title.Contains(keyword)) || + (image.FileName != null && image.FileName.Contains(keyword))); + } + + var items = await query + .OrderBy(image => image.Category) + .ThenBy(image => image.Title) + .Take(ResolveLimit(filter.Limit)) + .Select(image => new ImageAssetCatalogItem( + image.Id, + image.LegacyId, + image.Title, + image.Category, + image.FileName, + image.IsPublic, + image.CdnUrl, + image.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetAppAssetsAsync( + AssetFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.AppAssets + .AsNoTracking() + .Where(asset => asset.TenantId == filter.TenantId); + + if (!string.IsNullOrWhiteSpace(filter.AssetKey)) + { + var assetKey = filter.AssetKey.Trim(); + query = query.Where(asset => asset.AssetKey == assetKey); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(asset => + asset.AssetKey.Contains(keyword) || + (asset.FileName != null && asset.FileName.Contains(keyword)) || + (asset.Description != null && asset.Description.Contains(keyword))); + } + + var items = await query + .OrderBy(asset => asset.AssetKey) + .Take(ResolveLimit(filter.Limit)) + .Select(asset => new AppAssetCatalogItem( + asset.Id, + asset.LegacyId, + asset.AssetKey, + asset.FileName, + asset.Description, + asset.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetVideoExplanationsAsync( + AssetFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VideoExplanations + .AsNoTracking() + .Where(video => video.TenantId == filter.TenantId); + + if (!filter.IncludeInactive) + { + query = query.Where(video => video.IsActive); + } + + if (filter.SubjectId.HasValue) + { + query = query.Where(video => video.SubjectId == filter.SubjectId.Value || video.SubjectId == null); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(video => + video.Title.Contains(keyword) || + (video.Description != null && video.Description.Contains(keyword))); + } + + var items = await query + .OrderBy(video => video.SortOrder) + .ThenBy(video => video.Title) + .Take(ResolveLimit(filter.Limit)) + .Select(video => new VideoExplanationCatalogItem( + video.Id, + video.LegacyId, + video.SubjectId, + video.Title, + video.Description, + video.VideoUrl, + video.ThumbnailUrl, + video.DurationSeconds, + video.KnowledgeTags, + video.IsGeneral, + video.Difficulty, + video.SortOrder, + video.IsActive, + video.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetQuestionVideosAsync( + AssetFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.QuestionVideos + .AsNoTracking() + .Where(video => video.TenantId == filter.TenantId); + + if (filter.QuestionId.HasValue) + { + query = query.Where(video => video.QuestionId == filter.QuestionId.Value); + } + + if (filter.AssetId.HasValue) + { + query = query.Where(video => video.VideoId == filter.AssetId.Value); + } + + var videoExplanations = dbContext.VideoExplanations.AsNoTracking(); + var items = await query + .OrderBy(video => video.SortOrder) + .ThenBy(video => video.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .GroupJoin( + videoExplanations, + questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId }, + video => new { video.TenantId, Id = (Guid?)video.Id }, + (questionVideo, videos) => new + { + QuestionVideo = questionVideo, + Video = videos.FirstOrDefault() + }) + .Select(row => new QuestionVideoCatalogItem( + row.QuestionVideo.Id, + row.QuestionVideo.LegacyId, + row.QuestionVideo.QuestionId, + row.QuestionVideo.VideoId, + row.QuestionVideo.VideoType, + row.QuestionVideo.SortOrder, + row.QuestionVideo.Metadata, + row.Video == null + ? null + : new VideoExplanationCatalogItem( + row.Video.Id, + row.Video.LegacyId, + row.Video.SubjectId, + row.Video.Title, + row.Video.Description, + row.Video.VideoUrl, + row.Video.ThumbnailUrl, + row.Video.DurationSeconds, + row.Video.KnowledgeTags, + row.Video.IsGeneral, + row.Video.Difficulty, + row.Video.SortOrder, + row.Video.IsActive, + row.Video.Metadata))) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + private static int ResolveLimit(int? limit) + { + return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index faa47f9..d8b72cd 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -1,12 +1,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; +using Tiku.Application.Assets; using Tiku.Application.Auth; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Storage; using Tiku.Application.StudyContent; +using Tiku.Infrastructure.Assets; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Catalog; using Tiku.Infrastructure.Content; @@ -42,6 +44,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddOptions() .Validate( AliyunOssOptions.BeValid, diff --git a/Tiku.IntegrationTests/Api/AssetEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetEndpointTests.cs new file mode 100644 index 0000000..e33e99f --- /dev/null +++ b/Tiku.IntegrationTests/Api/AssetEndpointTests.cs @@ -0,0 +1,212 @@ +using System.Net; +using System.Text.Json; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AssetEndpointTests +{ + [Fact] + public async Task Content_assets_return_public_active_assets_by_default() + { + var tenantId = Guid.NewGuid(); + var regionId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new ContentAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + RegionId = regionId, + Title = "公开讲义", + FileName = "handbook.pdf", + AssetType = ContentAssetType.Pdf, + StorageProvider = AssetStorageProvider.AliyunOss, + Bucket = "tiku-assets", + ObjectKey = $"{tenantId:N}/handbook.pdf", + MimeType = "application/pdf", + Visibility = ContentVisibility.Public, + Status = ContentStatus.Active, + SortOrder = 2 + }, + new ContentAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + RegionId = regionId, + Title = "会员讲义", + Visibility = ContentVisibility.Members, + Status = ContentStatus.Active, + SortOrder = 1 + }, + new ContentAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + RegionId = regionId, + Title = "归档讲义", + Visibility = ContentVisibility.Public, + Status = ContentStatus.Archived + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf"); + var items = await ReadItemsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var item = Assert.Single(items); + Assert.Equal("公开讲义", item.GetProperty("title").GetString()); + Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString()); + } + + [Fact] + public async Task Content_assets_can_include_locked_catalog_items_when_requested() + { + var tenantId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new ContentAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Title = "会员讲义", + Visibility = ContentVisibility.Members, + Status = ContentStatus.Active + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/catalog/content-assets?tenantCode=master&includeLocked=true"); + var items = await ReadItemsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("会员讲义", Assert.Single(items).GetProperty("title").GetString()); + } + + [Fact] + public async Task Images_filter_private_items_until_include_locked() + { + var tenantId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new ImageAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Title = "公开图", + Category = "banner", + IsPublic = true + }, + new ImageAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Title = "内部图", + Category = "banner", + IsPublic = false + }); + using var client = factory.CreateClient(); + + using var publicResponse = await client.GetAsync("/api/catalog/images?tenantCode=master&category=banner"); + using var lockedResponse = await client.GetAsync("/api/catalog/images?tenantCode=master&category=banner&includeLocked=true"); + + Assert.Equal(["公开图"], (await ReadItemsAsync(publicResponse)).Select(item => item.GetProperty("title").GetString()!).ToArray()); + var lockedTitles = (await ReadItemsAsync(lockedResponse)) + .Select(item => item.GetProperty("title").GetString()!) + .ToArray(); + Assert.Equal(2, lockedTitles.Length); + Assert.Contains("内部图", lockedTitles); + Assert.Contains("公开图", lockedTitles); + } + + [Fact] + public async Task App_assets_support_asset_key_lookup() + { + var tenantId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new AppAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + AssetKey = "logo", + FileName = "logo.png" + }, + new AppAsset + { + Id = Guid.NewGuid(), + TenantId = tenantId, + AssetKey = "icon", + FileName = "icon.png" + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/catalog/app-assets?tenantCode=master&assetKey=logo"); + var item = Assert.Single(await ReadItemsAsync(response)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("logo", item.GetProperty("assetKey").GetString()); + } + + [Fact] + public async Task Question_videos_include_video_explanation_summary() + { + var tenantId = Guid.NewGuid(); + var questionId = Guid.NewGuid(); + var videoId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new VideoExplanation + { + Id = videoId, + TenantId = tenantId, + Title = "解题视频", + IsActive = true, + SortOrder = 1 + }, + new QuestionVideo + { + Id = Guid.NewGuid(), + TenantId = tenantId, + QuestionId = questionId, + VideoId = videoId, + VideoType = QuestionVideoType.Specific + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/catalog/question-videos?tenantCode=master&questionId={questionId}"); + var item = Assert.Single(await ReadItemsAsync(response)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(questionId, item.GetProperty("questionId").GetGuid()); + Assert.Equal("解题视频", item.GetProperty("video").GetProperty("title").GetString()); + } + + private static Tenant Tenant(Guid id, string slug) + { + return new Tenant + { + Id = id, + Slug = slug, + Name = slug, + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }; + } + + private static async Task ReadItemsAsync(HttpResponseMessage response) + { + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return body.RootElement + .GetProperty("items") + .EnumerateArray() + .Select(item => item.Clone()) + .ToArray(); + } +}