feat: add study content readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:21:42 +08:00
parent 97e7819a34
commit 47613b51bd
8 changed files with 727 additions and 3 deletions

View File

@@ -246,10 +246,10 @@ dotnet ef migrations script \
建议下一批迁移顺序:
```text
Vocabulary / Handbook 只读 API
-> 词汇单元 / 单词 / 知识手册
Assets / Video 只读 API
-> 内容资源 / 图片 / 视频解析
Operations Content 只读 API
-> 横幅 / FAQ / 公告
```
## 当前状态
@@ -261,5 +261,6 @@ Assets / Video 只读 API
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
- Question Bank 只读 API 已建立:题库列表、题目列表、题目详情、题目版本。
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank 只读测试通过
- Vocabulary / Handbook 只读 API 已建立:词汇单元、词汇单词、知识手册科目、章节、条目
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank / Study Content 只读测试通过。
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。

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

View File

@@ -6,6 +6,7 @@ using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Application.StudyContent;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -19,6 +20,7 @@ public sealed class CatalogController(
ICatalogQueryService catalogQueryService,
IContentNavigationQueryService contentNavigationQueryService,
IQuestionBankQueryService questionBankQueryService,
IStudyContentQueryService studyContentQueryService,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
{
@@ -236,6 +238,71 @@ public sealed class CatalogController(
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(
CatalogQueryDto query,
CancellationToken cancellationToken)
@@ -284,6 +351,18 @@ public sealed class CatalogController(
},
cancellationToken);
}
private Task<Guid> ResolveTenantIdAsync(
StudyContentQueryDto query,
CancellationToken cancellationToken)
{
return ResolveTenantIdAsync(
new CatalogQueryDto
{
TenantCode = query.TenantCode
},
cancellationToken);
}
}
public sealed class TenantNotFoundException : Exception

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

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

View File

@@ -5,11 +5,13 @@ using Tiku.Application.Auth;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.StudyContent;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.StudyContent;
namespace Tiku.Infrastructure;
@@ -37,6 +39,7 @@ public static class DependencyInjection
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
return services;
}

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

View 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&regionId={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();
}
}