feat: add asset and video readonly endpoints
This commit is contained in:
60
Tiku.Api/Contracts/AssetDtos.cs
Normal file
60
Tiku.Api/Contracts/AssetDtos.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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<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(
|
||||
CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -363,6 +430,18 @@ public sealed class CatalogController(
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
AssetQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveTenantIdAsync(
|
||||
new CatalogQueryDto
|
||||
{
|
||||
TenantCode = query.TenantCode
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantNotFoundException : Exception
|
||||
|
||||
95
Tiku.Application/Assets/AssetQueryModels.cs
Normal file
95
Tiku.Application/Assets/AssetQueryModels.cs
Normal 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);
|
||||
26
Tiku.Application/Assets/IAssetQueryService.cs
Normal file
26
Tiku.Application/Assets/IAssetQueryService.cs
Normal 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);
|
||||
}
|
||||
323
Tiku.Infrastructure/Assets/AssetQueryService.cs
Normal file
323
Tiku.Infrastructure/Assets/AssetQueryService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate(
|
||||
AliyunOssOptions.BeValid,
|
||||
|
||||
212
Tiku.IntegrationTests/Api/AssetEndpointTests.cs
Normal file
212
Tiku.IntegrationTests/Api/AssetEndpointTests.cs
Normal 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®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<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return body.RootElement
|
||||
.GetProperty("items")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user