feat: add catalog readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:02:04 +08:00
parent 41a8a5ff81
commit d5379affba
10 changed files with 962 additions and 53 deletions

View File

@@ -241,13 +241,11 @@ dotnet ef migrations script \
4. 业务流程放到 `Tiku.Application`EF/外部服务实现放到 `Tiku.Infrastructure`
5. 已登录业务接口默认从 `ICurrentTenant` / `ICurrentUser` 取上下文,不直接信任 body 里的 `tenantId`
6. 公开接口只返回 branding、feature flags、public config 等可暴露字段,不泄露 secret/refund/payment/internal metadata。
7. 每迁一个小闭环就补集成测试和 Scalar/OpenAPI 描述,关键 request / response schema 要有字段 description 断言,测试通过后单独提交
7. 每迁一个小闭环就补业务集成测试OpenAPI 做轻量 smoke字段注释靠迁移时同步维护不做逐字段断言
建议下一批迁移顺序:
```text
Catalog 基础只读 API
-> 地区 / 模块 / 学校 / 专业 / 科目 / 分类
Content Navigation 只读 API
-> 内容入口 / 内容树 / 题集 / 练习蓝图
Question Bank 只读 API
@@ -260,5 +258,6 @@ Question Bank 只读 API
- migration 已整理为单个初始建库 migration。
- API 安全底座已建立JWT、Session、本地登录、当前用户、当前租户、基础授权策略。
- 租户公开入口已建立tenant resolve、public config、health。
- 当前模型测试、认证服务测试、API 认证/租户闭环测试通过
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog 只读测试通过。
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。

View File

@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using Tiku.Application.Catalog;
namespace Tiku.Api.Contracts;
public sealed class CatalogQueryDto
{
/// <summary>
/// 租户编码。未登录公开查询时使用;已登录时优先使用当前 token 的租户。
/// </summary>
[StringLength(100)]
public string? TenantCode { get; set; }
/// <summary>
/// 地区 ID。
/// </summary>
public Guid? RegionId { get; set; }
/// <summary>
/// 功能模块 ID。
/// </summary>
public Guid? ModuleId { get; set; }
/// <summary>
/// 父节点 ID传 root 表示根节点。
/// </summary>
[StringLength(64)]
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
public string? ParentId { get; set; }
/// <summary>
/// 院校 ID。
/// </summary>
public Guid? SchoolId { get; set; }
/// <summary>
/// 专业 ID。
/// </summary>
public Guid? MajorId { get; set; }
/// <summary>
/// 科目 ID。
/// </summary>
public Guid? SubjectId { get; set; }
/// <summary>
/// 导航节点 ID。
/// </summary>
public Guid? NodeId { get; set; }
/// <summary>
/// 名称关键词。
/// </summary>
[StringLength(100)]
public string? Keyword { get; set; }
/// <summary>
/// 类型过滤,例如 cultural、professional、chapter、paper。
/// </summary>
[StringLength(50)]
public string? Type { get; set; }
/// <summary>
/// 返回条数上限。
/// </summary>
[Range(1, 2000)]
public int? Limit { get; set; }
public CatalogFilter ToFilter(Guid tenantId)
{
var parentIsRoot = string.Equals(ParentId, "root", StringComparison.OrdinalIgnoreCase);
Guid? parentId = parentIsRoot || string.IsNullOrWhiteSpace(ParentId)
? null
: Guid.Parse(ParentId);
return new CatalogFilter(
tenantId,
RegionId,
ModuleId,
parentId,
parentIsRoot,
SchoolId,
MajorId,
SubjectId,
NodeId,
Keyword,
Type,
Limit);
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Produces("application/json")]
[Route("api/catalog")]
public sealed class CatalogController(
ICatalogQueryService catalogQueryService,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
{
[HttpGet("regions")]
[EndpointSummary("查询可用地区")]
[ProducesResponseType<CatalogList<RegionCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<RegionCatalogItem>>> GetRegions(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetRegionsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("region-modules")]
[EndpointSummary("查询地区功能模块")]
[ProducesResponseType<CatalogList<RegionModuleCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<RegionModuleCatalogItem>>> GetRegionModules(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetRegionModulesAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("module-nodes")]
[EndpointSummary("查询模块导航节点")]
[ProducesResponseType<CatalogList<ModuleNodeCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ModuleNodeCatalogItem>>> GetModuleNodes(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetModuleNodesAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("schools")]
[EndpointSummary("查询院校目录")]
[ProducesResponseType<CatalogList<SchoolCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<SchoolCatalogItem>>> GetSchools(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetSchoolsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("majors")]
[EndpointSummary("查询专业目录")]
[ProducesResponseType<CatalogList<MajorCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<MajorCatalogItem>>> GetMajors(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetMajorsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("subjects")]
[EndpointSummary("查询科目目录")]
[ProducesResponseType<CatalogList<SubjectCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<SubjectCatalogItem>>> GetSubjects(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetSubjectsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("categories")]
[HttpGet("question-categories")]
[EndpointSummary("查询题目分类")]
[ProducesResponseType<CatalogList<CategoryCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<CategoryCatalogItem>>> GetCategories(
[FromQuery] CatalogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await catalogQueryService.GetCategoriesAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
private async Task<Guid> ResolveTenantIdAsync(
CatalogQueryDto query,
CancellationToken cancellationToken)
{
if (currentTenant.TenantId.HasValue)
{
return currentTenant.TenantId.Value;
}
var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenantCode))
{
throw new TenantNotFoundException();
}
var tenantId = await dbContext.Tenants
.Where(tenant =>
tenant.Slug == tenantCode.Trim() &&
tenant.Status == TenantStatus.Active)
.Select(tenant => (Guid?)tenant.Id)
.SingleOrDefaultAsync(cancellationToken);
return tenantId ?? throw new TenantNotFoundException();
}
}
public sealed class TenantNotFoundException : Exception
{
public TenantNotFoundException()
: base("Tenant was not found.")
{
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Auth;
namespace Tiku.Api.Middleware;
@@ -22,6 +23,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is TenantNotFoundException)
{
await WriteProblemAsync(
context,
"Tenant was not found.",
StatusCodes.Status404NotFound,
"tenant_not_found");
return;
}
logger.LogError(exception, "Unhandled API exception");
var problem = new ProblemDetails
@@ -38,6 +49,25 @@ public sealed class ExceptionHandlingMiddleware(
}
}
private static async Task WriteProblemAsync(
HttpContext context,
string title,
int status,
string code)
{
var problem = new ProblemDetails
{
Title = title,
Status = status,
Instance = context.Request.Path
};
problem.Extensions["code"] = code;
problem.Extensions["traceId"] = context.TraceIdentifier;
context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(problem);
}
private static async Task WriteAuthProblemAsync(HttpContext context, AuthException exception)
{
var status = exception.Code switch

View File

@@ -0,0 +1,103 @@
using System.Text.Json;
using Tiku.Domain.Catalog;
namespace Tiku.Application.Catalog;
public sealed record CatalogList<TItem>(IReadOnlyCollection<TItem> Items);
public sealed record CatalogFilter(
Guid TenantId,
Guid? RegionId = null,
Guid? ModuleId = null,
Guid? ParentId = null,
bool ParentIsRoot = false,
Guid? SchoolId = null,
Guid? MajorId = null,
Guid? SubjectId = null,
Guid? NodeId = null,
string? Keyword = null,
string? Type = null,
int? Limit = null);
public sealed record RegionCatalogItem(
Guid Id,
string? LegacyId,
string Name,
string? Code,
string? ShortName,
string? FullName,
string? Icon,
string? Pinyin,
int Order,
bool IsHot);
public sealed record RegionModuleCatalogItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
string Name,
string? Type,
string? Icon,
string? Color,
string? TextColor,
string? Description,
string? Route,
int Order,
bool IsPrimarySchoolModule);
public sealed record ModuleNodeCatalogItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
Guid? ModuleId,
Guid? ParentId,
string? LegacyParentId,
ModuleNodeType Type,
string Name,
string? Path,
int Order,
JsonElement Metadata);
public sealed record SchoolCatalogItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
Guid? ModuleId,
string Name,
string? ProfessionalExamDate,
JsonElement Metadata);
public sealed record MajorCatalogItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
Guid? SchoolId,
string Name,
string? Description,
string? StudyTips,
int Order);
public sealed record SubjectCatalogItem(
Guid Id,
string? LegacyId,
Guid? RegionId,
Guid? ModuleId,
Guid? SchoolId,
Guid? MajorId,
Guid? NodeId,
string Name,
SubjectType? Type,
string? Icon,
string? Description,
JsonElement Stats,
int Order);
public sealed record CategoryCatalogItem(
Guid Id,
string? LegacyId,
Guid? SubjectId,
Guid? NodeId,
string Name,
CategoryType? CategoryType,
int Order,
int? SvipQuestionLimit);

View File

@@ -0,0 +1,32 @@
namespace Tiku.Application.Catalog;
public interface ICatalogQueryService
{
Task<CatalogList<RegionCatalogItem>> GetRegionsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<RegionModuleCatalogItem>> GetRegionModulesAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<ModuleNodeCatalogItem>> GetModuleNodesAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<SchoolCatalogItem>> GetSchoolsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<MajorCatalogItem>> GetMajorsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<SubjectCatalogItem>> GetSubjectsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<CategoryCatalogItem>> GetCategoriesAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,368 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Domain.Catalog;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Catalog;
public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQueryService
{
private const int DefaultLimit = 500;
private const int MaxLimit = 2000;
public async Task<CatalogList<RegionCatalogItem>> GetRegionsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Regions
.AsNoTracking()
.Where(region =>
region.TenantId == filter.TenantId &&
region.IsActive);
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(region => region.SortOrder)
.ThenBy(region => region.Name)
.ThenBy(region => region.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(region => new RegionCatalogItem(
region.Id,
region.LegacyId,
region.Name,
region.Code,
region.ShortName,
region.FullName,
region.Icon,
region.Pinyin,
region.SortOrder,
region.IsHot))
.ToArrayAsync(cancellationToken);
return new CatalogList<RegionCatalogItem>(items);
}
public async Task<CatalogList<RegionModuleCatalogItem>> GetRegionModulesAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.RegionModules
.AsNoTracking()
.Where(module =>
module.TenantId == filter.TenantId &&
module.IsActive);
if (filter.RegionId.HasValue)
{
query = query.Where(module => module.RegionId == filter.RegionId.Value);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(module => module.SortOrder)
.ThenBy(module => module.Name)
.ThenBy(module => module.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(module => new RegionModuleCatalogItem(
module.Id,
module.LegacyId,
module.RegionId,
module.Name,
module.Type,
module.Icon,
module.Color,
module.TextColor,
module.Description,
module.Route,
module.SortOrder,
module.IsPrimarySchoolModule))
.ToArrayAsync(cancellationToken);
return new CatalogList<RegionModuleCatalogItem>(items);
}
public async Task<CatalogList<ModuleNodeCatalogItem>> GetModuleNodesAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ModuleNodes
.AsNoTracking()
.Where(node =>
node.TenantId == filter.TenantId &&
node.IsActive);
if (filter.RegionId.HasValue)
{
query = query.Where(node => node.RegionId == filter.RegionId.Value);
}
if (filter.ModuleId.HasValue)
{
query = query.Where(node => node.ModuleId == filter.ModuleId.Value);
}
if (filter.ParentIsRoot)
{
query = query.Where(node => node.ParentId == null);
}
else if (filter.ParentId.HasValue)
{
query = query.Where(node => node.ParentId == filter.ParentId.Value);
}
if (TryParseModuleNodeType(filter.Type, out var nodeType))
{
query = query.Where(node => node.Type == nodeType);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(node => node.SortOrder)
.ThenBy(node => node.Name)
.ThenBy(node => node.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(node => new ModuleNodeCatalogItem(
node.Id,
node.LegacyId,
node.RegionId,
node.ModuleId,
node.ParentId,
node.LegacyParentId,
node.Type,
node.Name,
node.Path,
node.SortOrder,
node.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<ModuleNodeCatalogItem>(items);
}
public async Task<CatalogList<SchoolCatalogItem>> GetSchoolsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Schools
.AsNoTracking()
.Where(school => school.TenantId == filter.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(school => school.RegionId == filter.RegionId.Value);
}
if (filter.ModuleId.HasValue)
{
query = query.Where(school => school.ModuleId == filter.ModuleId.Value);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(school => school.Name)
.ThenBy(school => school.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(school => new SchoolCatalogItem(
school.Id,
school.LegacyId,
school.RegionId,
school.ModuleId,
school.Name,
school.ProfessionalExamDate,
school.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<SchoolCatalogItem>(items);
}
public async Task<CatalogList<MajorCatalogItem>> GetMajorsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Majors
.AsNoTracking()
.Where(major =>
major.TenantId == filter.TenantId &&
major.IsActive);
if (filter.RegionId.HasValue)
{
query = query.Where(major => major.RegionId == filter.RegionId.Value);
}
if (filter.SchoolId.HasValue)
{
query = query.Where(major => major.SchoolId == filter.SchoolId.Value);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(major => major.SortOrder)
.ThenBy(major => major.Name)
.ThenBy(major => major.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(major => new MajorCatalogItem(
major.Id,
major.LegacyId,
major.RegionId,
major.SchoolId,
major.Name,
major.Description,
major.StudyTips,
major.SortOrder))
.ToArrayAsync(cancellationToken);
return new CatalogList<MajorCatalogItem>(items);
}
public async Task<CatalogList<SubjectCatalogItem>> GetSubjectsAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Subjects
.AsNoTracking()
.Where(subject =>
subject.TenantId == filter.TenantId &&
subject.IsActive);
if (filter.RegionId.HasValue)
{
query = query.Where(subject => subject.RegionId == filter.RegionId.Value);
}
if (filter.SchoolId.HasValue)
{
query = query.Where(subject => subject.SchoolId == filter.SchoolId.Value);
}
if (filter.MajorId.HasValue)
{
query = query.Where(subject => subject.MajorId == filter.MajorId.Value);
}
if (filter.ModuleId.HasValue)
{
query = query.Where(subject => subject.ModuleId == filter.ModuleId.Value);
}
if (TryParseSubjectType(filter.Type, out var subjectType))
{
query = query.Where(subject => subject.Type == subjectType);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(subject => subject.SortOrder)
.ThenBy(subject => subject.Name)
.ThenBy(subject => subject.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(subject => new SubjectCatalogItem(
subject.Id,
subject.LegacyId,
subject.RegionId,
subject.ModuleId,
subject.SchoolId,
subject.MajorId,
subject.NodeId,
subject.Name,
subject.Type,
subject.Icon,
subject.Description,
subject.Stats,
subject.SortOrder))
.ToArrayAsync(cancellationToken);
return new CatalogList<SubjectCatalogItem>(items);
}
public async Task<CatalogList<CategoryCatalogItem>> GetCategoriesAsync(
CatalogFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Categories
.AsNoTracking()
.Where(category =>
category.TenantId == filter.TenantId &&
category.IsActive);
if (filter.SubjectId.HasValue)
{
query = query.Where(category => category.SubjectId == filter.SubjectId.Value);
}
if (filter.NodeId.HasValue)
{
query = query.Where(category => category.NodeId == filter.NodeId.Value);
}
if (TryParseCategoryType(filter.Type, out var categoryType))
{
query = query.Where(category => category.CategoryType == categoryType);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(category => category.SortOrder)
.ThenBy(category => category.Name)
.ThenBy(category => category.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(category => new CategoryCatalogItem(
category.Id,
category.LegacyId,
category.SubjectId,
category.NodeId,
category.Name,
category.CategoryType,
category.SortOrder,
category.SvipQuestionLimit))
.ToArrayAsync(cancellationToken);
return new CatalogList<CategoryCatalogItem>(items);
}
private static IQueryable<T> ApplyKeyword<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, nameof(Region.Name)).Contains(trimmed));
}
private static int ResolveLimit(int? limit)
{
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
}
private static bool TryParseSubjectType(string? value, out SubjectType type)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
}
private static bool TryParseModuleNodeType(string? value, out ModuleNodeType type)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
}
private static bool TryParseCategoryType(string? value, out CategoryType type)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
}
private static string? NormalizeEnumValue(string? value)
{
return string.IsNullOrWhiteSpace(value)
? null
: value.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
}
}

View File

@@ -2,7 +2,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Auth;
using Tiku.Application.Catalog;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure;
@@ -28,6 +30,7 @@ public static class DependencyInjection
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddHttpClient<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
return services;
}

View File

@@ -0,0 +1,185 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
namespace Tiku.IntegrationTests.Api;
public sealed class CatalogEndpointTests
{
[Fact]
public async Task Regions_are_filtered_by_active_tenant()
{
var tenantId = Guid.NewGuid();
var otherTenantId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
Tenant(otherTenantId, "other"),
new Region
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = "浙江",
SortOrder = 2,
IsActive = true
},
new Region
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = "北京",
SortOrder = 1,
IsActive = true
},
new Region
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = "停用地区",
SortOrder = 0,
IsActive = false
},
new Region
{
Id = Guid.NewGuid(),
TenantId = otherTenantId,
Name = "其他租户地区",
IsActive = true
});
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/catalog/regions?tenantCode=master");
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 Subjects_support_common_filters()
{
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var majorId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
new School { Id = schoolId, TenantId = tenantId, RegionId = regionId, Name = "测试院校" },
new Major { Id = majorId, TenantId = tenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" },
new Subject
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = regionId,
SchoolId = schoolId,
MajorId = majorId,
Name = "专业理论",
Type = SubjectType.Professional,
SortOrder = 1,
IsActive = true
},
new Subject
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = regionId,
SchoolId = schoolId,
MajorId = majorId,
Name = "公共英语",
Type = SubjectType.Cultural,
SortOrder = 2,
IsActive = true
},
new Subject
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = regionId,
Name = "停用科目",
Type = SubjectType.Professional,
IsActive = false
});
using var client = factory.CreateClient();
using var response = await client.GetAsync(
$"/api/catalog/subjects?tenantCode=master&regionId={regionId}&schoolId={schoolId}&majorId={majorId}&type=professional&keyword=理论");
var items = await ReadItemsAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var item = Assert.Single(items);
Assert.Equal("专业理论", item.GetProperty("name").GetString());
}
[Fact]
public async Task Module_nodes_support_root_filter()
{
var tenantId = Guid.NewGuid();
var parentId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new ModuleNode
{
Id = parentId,
TenantId = tenantId,
Name = "根节点",
Type = ModuleNodeType.Category,
IsActive = true
},
new ModuleNode
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ParentId = parentId,
Name = "子节点",
Type = ModuleNodeType.Chapter,
IsActive = true
});
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/catalog/module-nodes?tenantCode=master&parentId=root");
var items = await ReadItemsAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var item = Assert.Single(items);
Assert.Equal("根节点", item.GetProperty("name").GetString());
}
[Fact]
public async Task Catalog_requires_known_tenant_for_anonymous_requests()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/catalog/regions");
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("tenant_not_found", body.RootElement.GetProperty("code").GetString());
}
private static Tenant Tenant(Guid id, string slug)
{
return new Tenant
{
Id = id,
Slug = slug,
Name = slug,
Status = TenantStatus.Active
};
}
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();
}
}

View File

@@ -6,7 +6,7 @@ namespace Tiku.IntegrationTests.Api;
public sealed class OpenApiDocumentationTests
{
[Fact]
public async Task Openapi_includes_request_schema_property_descriptions()
public async Task Openapi_document_can_be_generated_with_key_paths()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
@@ -15,53 +15,7 @@ public sealed class OpenApiDocumentationTests
var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains(
"租户 ID",
GetSchemaPropertyDescription(document, "PasswordLoginDto", "tenantId"),
StringComparison.Ordinal);
Assert.Contains(
"手机号",
GetSchemaPropertyDescription(document, "PasswordLoginDto", "phone"),
StringComparison.Ordinal);
Assert.Contains(
"refresh token",
GetSchemaPropertyDescription(document, "RefreshSessionDto", "refreshToken"),
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Openapi_includes_response_schema_property_descriptions()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/openapi/v1.json");
var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains(
"access token",
GetSchemaPropertyDescription(document, "AuthTokenPair", "accessToken"),
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"租户名称",
GetSchemaPropertyDescription(document, "PublicTenantDto", "name"),
StringComparison.Ordinal);
}
private static string GetSchemaPropertyDescription(
JsonDocument document,
string schemaName,
string propertyName)
{
return document
.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty(schemaName)
.GetProperty("properties")
.GetProperty(propertyName)
.GetProperty("description")
.GetString() ?? string.Empty;
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/auth/login/password", out _));
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/catalog/regions", out _));
}
}