forked from gongxuegit/tiku-backend.net
feat: add study content readonly endpoints
This commit is contained in:
@@ -246,10 +246,10 @@ dotnet ef migrations script \
|
|||||||
建议下一批迁移顺序:
|
建议下一批迁移顺序:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Vocabulary / Handbook 只读 API
|
|
||||||
-> 词汇单元 / 单词 / 知识手册
|
|
||||||
Assets / Video 只读 API
|
Assets / Video 只读 API
|
||||||
-> 内容资源 / 图片 / 视频解析
|
-> 内容资源 / 图片 / 视频解析
|
||||||
|
Operations Content 只读 API
|
||||||
|
-> 横幅 / FAQ / 公告
|
||||||
```
|
```
|
||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
@@ -261,5 +261,6 @@ Assets / Video 只读 API
|
|||||||
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
|
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
|
||||||
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
|
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
|
||||||
- Question Bank 只读 API 已建立:题库列表、题目列表、题目详情、题目版本。
|
- Question Bank 只读 API 已建立:题库列表、题目列表、题目详情、题目版本。
|
||||||
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank 只读测试通过。
|
- Vocabulary / Handbook 只读 API 已建立:词汇单元、词汇单词、知识手册科目、章节、条目。
|
||||||
|
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank / Study Content 只读测试通过。
|
||||||
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。
|
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。
|
||||||
|
|||||||
45
Tiku.Api/Contracts/StudyContentDtos.cs
Normal file
45
Tiku.Api/Contracts/StudyContentDtos.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Tiku.Application.StudyContent;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Contracts;
|
||||||
|
|
||||||
|
public sealed class StudyContentQueryDto
|
||||||
|
{
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? TenantCode { get; set; }
|
||||||
|
|
||||||
|
public Guid? RegionId { get; set; }
|
||||||
|
|
||||||
|
public Guid? UnitId { get; set; }
|
||||||
|
|
||||||
|
public Guid? SubjectId { get; set; }
|
||||||
|
|
||||||
|
public Guid? ChapterId { get; set; }
|
||||||
|
|
||||||
|
public Guid? EntryId { get; set; }
|
||||||
|
|
||||||
|
public Guid? ContentNodeId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
public bool IncludeContent { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 2000)]
|
||||||
|
public int? Limit { get; set; }
|
||||||
|
|
||||||
|
public StudyContentFilter ToFilter(Guid tenantId)
|
||||||
|
{
|
||||||
|
return new StudyContentFilter(
|
||||||
|
tenantId,
|
||||||
|
RegionId,
|
||||||
|
UnitId,
|
||||||
|
SubjectId,
|
||||||
|
ChapterId,
|
||||||
|
EntryId,
|
||||||
|
ContentNodeId,
|
||||||
|
Keyword,
|
||||||
|
IncludeContent,
|
||||||
|
Limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Tiku.Application.Catalog;
|
|||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Application.StudyContent;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public sealed class CatalogController(
|
|||||||
ICatalogQueryService catalogQueryService,
|
ICatalogQueryService catalogQueryService,
|
||||||
IContentNavigationQueryService contentNavigationQueryService,
|
IContentNavigationQueryService contentNavigationQueryService,
|
||||||
IQuestionBankQueryService questionBankQueryService,
|
IQuestionBankQueryService questionBankQueryService,
|
||||||
|
IStudyContentQueryService studyContentQueryService,
|
||||||
ICurrentTenant currentTenant,
|
ICurrentTenant currentTenant,
|
||||||
TikuDbContext dbContext) : ControllerBase
|
TikuDbContext dbContext) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -236,6 +238,71 @@ public sealed class CatalogController(
|
|||||||
cancellationToken));
|
cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("vocabulary-units")]
|
||||||
|
[EndpointSummary("查询词汇单元")]
|
||||||
|
[ProducesResponseType<CatalogList<VocabularyUnitCatalogItem>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CatalogList<VocabularyUnitCatalogItem>>> GetVocabularyUnits(
|
||||||
|
[FromQuery] StudyContentQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await studyContentQueryService.GetVocabularyUnitsAsync(
|
||||||
|
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("vocabulary-words")]
|
||||||
|
[EndpointSummary("查询词汇单词")]
|
||||||
|
[ProducesResponseType<CatalogList<VocabularyWordCatalogItem>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CatalogList<VocabularyWordCatalogItem>>> GetVocabularyWords(
|
||||||
|
[FromQuery] StudyContentQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await studyContentQueryService.GetVocabularyWordsAsync(
|
||||||
|
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("handbook-subjects")]
|
||||||
|
[EndpointSummary("查询知识手册科目")]
|
||||||
|
[ProducesResponseType<CatalogList<HandbookSubjectCatalogItem>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CatalogList<HandbookSubjectCatalogItem>>> GetHandbookSubjects(
|
||||||
|
[FromQuery] StudyContentQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await studyContentQueryService.GetHandbookSubjectsAsync(
|
||||||
|
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("handbook-chapters")]
|
||||||
|
[EndpointSummary("查询知识手册章节")]
|
||||||
|
[ProducesResponseType<CatalogList<HandbookChapterCatalogItem>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CatalogList<HandbookChapterCatalogItem>>> GetHandbookChapters(
|
||||||
|
[FromQuery] StudyContentQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await studyContentQueryService.GetHandbookChaptersAsync(
|
||||||
|
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("handbook-entries")]
|
||||||
|
[EndpointSummary("查询知识手册条目")]
|
||||||
|
[ProducesResponseType<CatalogList<HandbookEntryCatalogItem>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CatalogList<HandbookEntryCatalogItem>>> GetHandbookEntries(
|
||||||
|
[FromQuery] StudyContentQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await studyContentQueryService.GetHandbookEntriesAsync(
|
||||||
|
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)
|
||||||
@@ -284,6 +351,18 @@ public sealed class CatalogController(
|
|||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<Guid> ResolveTenantIdAsync(
|
||||||
|
StudyContentQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ResolveTenantIdAsync(
|
||||||
|
new CatalogQueryDto
|
||||||
|
{
|
||||||
|
TenantCode = query.TenantCode
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TenantNotFoundException : Exception
|
public sealed class TenantNotFoundException : Exception
|
||||||
|
|||||||
26
Tiku.Application/StudyContent/IStudyContentQueryService.cs
Normal file
26
Tiku.Application/StudyContent/IStudyContentQueryService.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Tiku.Application.Catalog;
|
||||||
|
|
||||||
|
namespace Tiku.Application.StudyContent;
|
||||||
|
|
||||||
|
public interface IStudyContentQueryService
|
||||||
|
{
|
||||||
|
Task<CatalogList<VocabularyUnitCatalogItem>> GetVocabularyUnitsAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CatalogList<VocabularyWordCatalogItem>> GetVocabularyWordsAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CatalogList<HandbookSubjectCatalogItem>> GetHandbookSubjectsAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CatalogList<HandbookChapterCatalogItem>> GetHandbookChaptersAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CatalogList<HandbookEntryCatalogItem>> GetHandbookEntriesAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
84
Tiku.Application/StudyContent/StudyContentQueryModels.cs
Normal file
84
Tiku.Application/StudyContent/StudyContentQueryModels.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
|
||||||
|
namespace Tiku.Application.StudyContent;
|
||||||
|
|
||||||
|
public sealed record StudyContentFilter(
|
||||||
|
Guid TenantId,
|
||||||
|
Guid? RegionId = null,
|
||||||
|
Guid? UnitId = null,
|
||||||
|
Guid? SubjectId = null,
|
||||||
|
Guid? ChapterId = null,
|
||||||
|
Guid? EntryId = null,
|
||||||
|
Guid? ContentNodeId = null,
|
||||||
|
string? Keyword = null,
|
||||||
|
bool IncludeContent = false,
|
||||||
|
int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record VocabularyUnitCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
string? LegacyId,
|
||||||
|
Guid? RegionId,
|
||||||
|
Guid? EntryId,
|
||||||
|
Guid? ContentNodeId,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
int? WordCount,
|
||||||
|
int Order,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record VocabularyWordCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
string? LegacyId,
|
||||||
|
Guid? UnitId,
|
||||||
|
Guid? EntryId,
|
||||||
|
Guid? ContentNodeId,
|
||||||
|
string Word,
|
||||||
|
string? Phonetic,
|
||||||
|
string? Meaning,
|
||||||
|
string? Example,
|
||||||
|
string? ExampleTranslation,
|
||||||
|
int? Difficulty,
|
||||||
|
JsonElement Tags,
|
||||||
|
int Order,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record HandbookSubjectCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
string? LegacyId,
|
||||||
|
Guid? RegionId,
|
||||||
|
Guid? SchoolId,
|
||||||
|
Guid? MajorId,
|
||||||
|
Guid? EntryId,
|
||||||
|
Guid? ContentNodeId,
|
||||||
|
string Name,
|
||||||
|
HandbookSubjectType? Type,
|
||||||
|
string? Icon,
|
||||||
|
string? Color,
|
||||||
|
string? Description,
|
||||||
|
int Order,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record HandbookChapterCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
string? LegacyId,
|
||||||
|
Guid? SubjectId,
|
||||||
|
Guid? EntryId,
|
||||||
|
Guid? ContentNodeId,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
int Order,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record HandbookEntryCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
string? LegacyId,
|
||||||
|
Guid? ChapterId,
|
||||||
|
Guid? EntryId,
|
||||||
|
Guid? ContentNodeId,
|
||||||
|
string Title,
|
||||||
|
string? Summary,
|
||||||
|
string? Content,
|
||||||
|
JsonElement Tags,
|
||||||
|
int Order,
|
||||||
|
JsonElement Metadata);
|
||||||
@@ -5,11 +5,13 @@ 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.StudyContent;
|
||||||
using Tiku.Infrastructure.Auth;
|
using Tiku.Infrastructure.Auth;
|
||||||
using Tiku.Infrastructure.Catalog;
|
using Tiku.Infrastructure.Catalog;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
|
using Tiku.Infrastructure.StudyContent;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure;
|
namespace Tiku.Infrastructure;
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||||
|
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
284
Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs
Normal file
284
Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Application.Catalog;
|
||||||
|
using Tiku.Application.StudyContent;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.StudyContent;
|
||||||
|
|
||||||
|
public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyContentQueryService
|
||||||
|
{
|
||||||
|
private const int DefaultLimit = 500;
|
||||||
|
private const int MaxLimit = 2000;
|
||||||
|
|
||||||
|
public async Task<CatalogList<VocabularyUnitCatalogItem>> GetVocabularyUnitsAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = dbContext.VocabularyUnits
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(unit =>
|
||||||
|
unit.TenantId == filter.TenantId &&
|
||||||
|
unit.IsActive);
|
||||||
|
|
||||||
|
if (filter.RegionId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(unit => unit.RegionId == filter.RegionId.Value || unit.RegionId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.EntryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(unit => unit.EntryId == filter.EntryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ContentNodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(unit => unit.ContentNodeId == filter.ContentNodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = ApplyNameKeyword(query, filter.Keyword);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(unit => unit.SortOrder)
|
||||||
|
.ThenBy(unit => unit.CreatedAt)
|
||||||
|
.Take(ResolveLimit(filter.Limit))
|
||||||
|
.Select(unit => new VocabularyUnitCatalogItem(
|
||||||
|
unit.Id,
|
||||||
|
unit.LegacyId,
|
||||||
|
unit.RegionId,
|
||||||
|
unit.EntryId,
|
||||||
|
unit.ContentNodeId,
|
||||||
|
unit.Name,
|
||||||
|
unit.Description,
|
||||||
|
unit.WordCount,
|
||||||
|
unit.SortOrder,
|
||||||
|
unit.Metadata))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<VocabularyUnitCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CatalogList<VocabularyWordCatalogItem>> GetVocabularyWordsAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = dbContext.VocabularyWords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(word =>
|
||||||
|
word.TenantId == filter.TenantId &&
|
||||||
|
word.IsActive);
|
||||||
|
|
||||||
|
if (filter.UnitId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(word => word.UnitId == filter.UnitId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.EntryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(word => word.EntryId == filter.EntryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ContentNodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(word => word.ContentNodeId == filter.ContentNodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||||
|
{
|
||||||
|
var keyword = filter.Keyword.Trim();
|
||||||
|
query = query.Where(word =>
|
||||||
|
word.Word.Contains(keyword) ||
|
||||||
|
(word.Meaning != null && word.Meaning.Contains(keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(word => word.SortOrder)
|
||||||
|
.ThenBy(word => word.Word)
|
||||||
|
.Take(ResolveLimit(filter.Limit))
|
||||||
|
.Select(word => new VocabularyWordCatalogItem(
|
||||||
|
word.Id,
|
||||||
|
word.LegacyId,
|
||||||
|
word.UnitId,
|
||||||
|
word.EntryId,
|
||||||
|
word.ContentNodeId,
|
||||||
|
word.Word,
|
||||||
|
word.Phonetic,
|
||||||
|
word.Meaning,
|
||||||
|
word.Example,
|
||||||
|
word.ExampleTranslation,
|
||||||
|
word.Difficulty,
|
||||||
|
word.Tags,
|
||||||
|
word.SortOrder,
|
||||||
|
word.Metadata))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<VocabularyWordCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CatalogList<HandbookSubjectCatalogItem>> GetHandbookSubjectsAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = dbContext.HandbookSubjects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(subject =>
|
||||||
|
subject.TenantId == filter.TenantId &&
|
||||||
|
subject.IsActive);
|
||||||
|
|
||||||
|
if (filter.RegionId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(subject => subject.RegionId == filter.RegionId.Value || subject.RegionId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.EntryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(subject => subject.EntryId == filter.EntryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ContentNodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(subject => subject.ContentNodeId == filter.ContentNodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = ApplyNameKeyword(query, filter.Keyword);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(subject => subject.SortOrder)
|
||||||
|
.ThenBy(subject => subject.CreatedAt)
|
||||||
|
.Take(ResolveLimit(filter.Limit))
|
||||||
|
.Select(subject => new HandbookSubjectCatalogItem(
|
||||||
|
subject.Id,
|
||||||
|
subject.LegacyId,
|
||||||
|
subject.RegionId,
|
||||||
|
subject.SchoolId,
|
||||||
|
subject.MajorId,
|
||||||
|
subject.EntryId,
|
||||||
|
subject.ContentNodeId,
|
||||||
|
subject.Name,
|
||||||
|
subject.Type,
|
||||||
|
subject.Icon,
|
||||||
|
subject.Color,
|
||||||
|
subject.Description,
|
||||||
|
subject.SortOrder,
|
||||||
|
subject.Metadata))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<HandbookSubjectCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CatalogList<HandbookChapterCatalogItem>> GetHandbookChaptersAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = dbContext.HandbookChapters
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(chapter =>
|
||||||
|
chapter.TenantId == filter.TenantId &&
|
||||||
|
chapter.IsActive);
|
||||||
|
|
||||||
|
if (filter.SubjectId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(chapter => chapter.SubjectId == filter.SubjectId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.EntryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(chapter => chapter.EntryId == filter.EntryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ContentNodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(chapter => chapter.ContentNodeId == filter.ContentNodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = ApplyNameKeyword(query, filter.Keyword);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(chapter => chapter.SortOrder)
|
||||||
|
.ThenBy(chapter => chapter.CreatedAt)
|
||||||
|
.Take(ResolveLimit(filter.Limit))
|
||||||
|
.Select(chapter => new HandbookChapterCatalogItem(
|
||||||
|
chapter.Id,
|
||||||
|
chapter.LegacyId,
|
||||||
|
chapter.SubjectId,
|
||||||
|
chapter.EntryId,
|
||||||
|
chapter.ContentNodeId,
|
||||||
|
chapter.Name,
|
||||||
|
chapter.Description,
|
||||||
|
chapter.SortOrder,
|
||||||
|
chapter.Metadata))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<HandbookChapterCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CatalogList<HandbookEntryCatalogItem>> GetHandbookEntriesAsync(
|
||||||
|
StudyContentFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = dbContext.HandbookEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(entry =>
|
||||||
|
entry.TenantId == filter.TenantId &&
|
||||||
|
entry.IsActive);
|
||||||
|
|
||||||
|
if (filter.ChapterId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(entry => entry.ChapterId == filter.ChapterId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.EntryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(entry => entry.EntryId == filter.EntryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ContentNodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(entry => entry.ContentNodeId == filter.ContentNodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||||
|
{
|
||||||
|
var keyword = filter.Keyword.Trim();
|
||||||
|
query = query.Where(entry =>
|
||||||
|
entry.Title.Contains(keyword) ||
|
||||||
|
(entry.Summary != null && entry.Summary.Contains(keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(entry => entry.SortOrder)
|
||||||
|
.ThenBy(entry => entry.CreatedAt)
|
||||||
|
.Take(ResolveLimit(filter.Limit))
|
||||||
|
.Select(entry => new HandbookEntryCatalogItem(
|
||||||
|
entry.Id,
|
||||||
|
entry.LegacyId,
|
||||||
|
entry.ChapterId,
|
||||||
|
entry.EntryId,
|
||||||
|
entry.ContentNodeId,
|
||||||
|
entry.Title,
|
||||||
|
entry.Summary,
|
||||||
|
filter.IncludeContent ? entry.Content : null,
|
||||||
|
entry.Tags,
|
||||||
|
entry.SortOrder,
|
||||||
|
entry.Metadata))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<HandbookEntryCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<T> ApplyNameKeyword<T>(IQueryable<T> query, string? keyword)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = keyword.Trim();
|
||||||
|
return query.Where(entity => EF.Property<string>(entity, "Name").Contains(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveLimit(int? limit)
|
||||||
|
{
|
||||||
|
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs
Normal file
202
Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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 StudyContentEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Vocabulary_units_are_active_sorted_and_region_scoped()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var regionId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new VocabularyUnit
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
RegionId = regionId,
|
||||||
|
Name = "核心词汇",
|
||||||
|
SortOrder = 2,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new VocabularyUnit
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = "全局词汇",
|
||||||
|
SortOrder = 1,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new VocabularyUnit
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
RegionId = regionId,
|
||||||
|
Name = "停用词汇",
|
||||||
|
SortOrder = 0,
|
||||||
|
IsActive = false
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/catalog/vocabulary-units?tenantCode=master®ionId={regionId}");
|
||||||
|
var items = await ReadItemsAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(["全局词汇", "核心词汇"], items.Select(item => item.GetProperty("name").GetString()!).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Vocabulary_words_support_unit_and_keyword_filters()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new VocabularyUnit { Id = unitId, TenantId = tenantId, Name = "Unit 1" },
|
||||||
|
new VocabularyWord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
UnitId = unitId,
|
||||||
|
Word = "abandon",
|
||||||
|
Meaning = "放弃",
|
||||||
|
SortOrder = 1,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new VocabularyWord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
UnitId = unitId,
|
||||||
|
Word = "ability",
|
||||||
|
Meaning = "能力",
|
||||||
|
SortOrder = 2,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new VocabularyWord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
Word = "other",
|
||||||
|
Meaning = "其他",
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃");
|
||||||
|
var items = await ReadItemsAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var item = Assert.Single(items);
|
||||||
|
Assert.Equal("abandon", item.GetProperty("word").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handbook_chapters_are_filtered_by_subject()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new HandbookSubject
|
||||||
|
{
|
||||||
|
Id = subjectId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = "语文手册",
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new HandbookChapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
SubjectId = subjectId,
|
||||||
|
Name = "古诗词",
|
||||||
|
SortOrder = 1,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new HandbookChapter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = "其他章节",
|
||||||
|
SortOrder = 2,
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}");
|
||||||
|
var items = await ReadItemsAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("古诗词", Assert.Single(items).GetProperty("name").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handbook_entries_hide_content_until_requested()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var chapterId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new HandbookChapter
|
||||||
|
{
|
||||||
|
Id = chapterId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = "章节",
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new HandbookEntry
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
ChapterId = chapterId,
|
||||||
|
Title = "知识点",
|
||||||
|
Summary = "摘要",
|
||||||
|
Content = "完整正文",
|
||||||
|
SortOrder = 1,
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var listResponse = await client.GetAsync($"/api/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}");
|
||||||
|
using var detailResponse = await client.GetAsync($"/api/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true");
|
||||||
|
var listItem = Assert.Single(await ReadItemsAsync(listResponse));
|
||||||
|
var detailItem = Assert.Single(await ReadItemsAsync(detailResponse));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||||
|
Assert.Equal(JsonValueKind.Null, listItem.GetProperty("content").ValueKind);
|
||||||
|
Assert.Equal("完整正文", detailItem.GetProperty("content").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