feat: add asset and video readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:48:15 +08:00
parent c6051f95f2
commit 0ff97e86cd
7 changed files with 798 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Api.Contracts; using Tiku.Api.Contracts;
using Tiku.Application.Catalog; using Tiku.Application.Catalog;
using Tiku.Application.Content; using Tiku.Application.Content;
@@ -21,6 +22,7 @@ public sealed class CatalogController(
IContentNavigationQueryService contentNavigationQueryService, IContentNavigationQueryService contentNavigationQueryService,
IQuestionBankQueryService questionBankQueryService, IQuestionBankQueryService questionBankQueryService,
IStudyContentQueryService studyContentQueryService, IStudyContentQueryService studyContentQueryService,
IAssetQueryService assetQueryService,
ICurrentTenant currentTenant, ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase TikuDbContext dbContext) : ControllerBase
{ {
@@ -303,6 +305,71 @@ public sealed class CatalogController(
cancellationToken)); cancellationToken));
} }
[HttpGet("content-assets")]
[EndpointSummary("查询内容资源")]
[ProducesResponseType<CatalogList<ContentAssetCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ContentAssetCatalogItem>>> GetContentAssets(
[FromQuery] AssetQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetQueryService.GetContentAssetsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("images")]
[EndpointSummary("查询图片资源")]
[ProducesResponseType<CatalogList<ImageAssetCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ImageAssetCatalogItem>>> GetImages(
[FromQuery] AssetQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetQueryService.GetImagesAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("app-assets")]
[EndpointSummary("查询应用资源")]
[ProducesResponseType<CatalogList<AppAssetCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<AppAssetCatalogItem>>> GetAppAssets(
[FromQuery] AssetQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetQueryService.GetAppAssetsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("video-explanations")]
[EndpointSummary("查询视频讲解")]
[ProducesResponseType<CatalogList<VideoExplanationCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<VideoExplanationCatalogItem>>> GetVideoExplanations(
[FromQuery] AssetQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetQueryService.GetVideoExplanationsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("question-videos")]
[EndpointSummary("查询题目关联视频")]
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<QuestionVideoCatalogItem>>> GetQuestionVideos(
[FromQuery] AssetQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetQueryService.GetQuestionVideosAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
private async Task<Guid> ResolveTenantIdAsync( private async Task<Guid> ResolveTenantIdAsync(
CatalogQueryDto query, CatalogQueryDto query,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -363,6 +430,18 @@ public sealed class CatalogController(
}, },
cancellationToken); cancellationToken);
} }
private Task<Guid> ResolveTenantIdAsync(
AssetQueryDto query,
CancellationToken cancellationToken)
{
return ResolveTenantIdAsync(
new CatalogQueryDto
{
TenantCode = query.TenantCode
},
cancellationToken);
}
} }
public sealed class TenantNotFoundException : Exception public sealed class TenantNotFoundException : Exception

View File

@@ -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);

View File

@@ -0,0 +1,26 @@
using Tiku.Application.Catalog;
namespace Tiku.Application.Assets;
public interface IAssetQueryService
{
Task<CatalogList<ContentAssetCatalogItem>> GetContentAssetsAsync(
AssetFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<ImageAssetCatalogItem>> GetImagesAsync(
AssetFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<AppAssetCatalogItem>> GetAppAssetsAsync(
AssetFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<VideoExplanationCatalogItem>> GetVideoExplanationsAsync(
AssetFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
AssetFilter filter,
CancellationToken cancellationToken = default);
}

View File

@@ -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<CatalogList<ContentAssetCatalogItem>> 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<ContentAssetType>(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<ContentAssetCatalogItem>(items);
}
public async Task<CatalogList<ImageAssetCatalogItem>> 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<ImageAssetCatalogItem>(items);
}
public async Task<CatalogList<AppAssetCatalogItem>> 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<AppAssetCatalogItem>(items);
}
public async Task<CatalogList<VideoExplanationCatalogItem>> 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<VideoExplanationCatalogItem>(items);
}
public async Task<CatalogList<QuestionVideoCatalogItem>> 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<QuestionVideoCatalogItem>(items);
}
private static int ResolveLimit(int? limit)
{
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
}
}

View File

@@ -1,12 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Npgsql; using Npgsql;
using Tiku.Application.Assets;
using Tiku.Application.Auth; using Tiku.Application.Auth;
using Tiku.Application.Catalog; using Tiku.Application.Catalog;
using Tiku.Application.Content; using Tiku.Application.Content;
using Tiku.Application.QuestionBanks; using Tiku.Application.QuestionBanks;
using Tiku.Application.Storage; using Tiku.Application.Storage;
using Tiku.Application.StudyContent; using Tiku.Application.StudyContent;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Catalog; using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Content;
@@ -42,6 +44,7 @@ public static class DependencyInjection
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>(); services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>(); services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>(); services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
services.AddScoped<IAssetQueryService, AssetQueryService>();
services.AddOptions<AliyunOssOptions>() services.AddOptions<AliyunOssOptions>()
.Validate( .Validate(
AliyunOssOptions.BeValid, AliyunOssOptions.BeValid,

View File

@@ -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&regionId={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<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
{
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return body.RootElement
.GetProperty("items")
.EnumerateArray()
.Select(item => item.Clone())
.ToArray();
}
}