diff --git a/README.md b/README.md
index 5fc54fa..a754c22 100644
--- a/README.md
+++ b/README.md
@@ -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 接入这套安全轨道,而不是重新散写权限判断。
diff --git a/Tiku.Api/Contracts/CatalogDtos.cs b/Tiku.Api/Contracts/CatalogDtos.cs
new file mode 100644
index 0000000..4558010
--- /dev/null
+++ b/Tiku.Api/Contracts/CatalogDtos.cs
@@ -0,0 +1,90 @@
+using System.ComponentModel.DataAnnotations;
+using Tiku.Application.Catalog;
+
+namespace Tiku.Api.Contracts;
+
+public sealed class CatalogQueryDto
+{
+ ///
+ /// 租户编码。未登录公开查询时使用;已登录时优先使用当前 token 的租户。
+ ///
+ [StringLength(100)]
+ public string? TenantCode { get; set; }
+
+ ///
+ /// 地区 ID。
+ ///
+ public Guid? RegionId { get; set; }
+
+ ///
+ /// 功能模块 ID。
+ ///
+ public Guid? ModuleId { get; set; }
+
+ ///
+ /// 父节点 ID;传 root 表示根节点。
+ ///
+ [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; }
+
+ ///
+ /// 院校 ID。
+ ///
+ public Guid? SchoolId { get; set; }
+
+ ///
+ /// 专业 ID。
+ ///
+ public Guid? MajorId { get; set; }
+
+ ///
+ /// 科目 ID。
+ ///
+ public Guid? SubjectId { get; set; }
+
+ ///
+ /// 导航节点 ID。
+ ///
+ public Guid? NodeId { get; set; }
+
+ ///
+ /// 名称关键词。
+ ///
+ [StringLength(100)]
+ public string? Keyword { get; set; }
+
+ ///
+ /// 类型过滤,例如 cultural、professional、chapter、paper。
+ ///
+ [StringLength(50)]
+ public string? Type { get; set; }
+
+ ///
+ /// 返回条数上限。
+ ///
+ [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);
+ }
+}
diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs
new file mode 100644
index 0000000..4cc578c
--- /dev/null
+++ b/Tiku.Api/Controllers/CatalogController.cs
@@ -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>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetRegions(
+ [FromQuery] CatalogQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await catalogQueryService.GetRegionsAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
+ cancellationToken));
+ }
+
+ [HttpGet("region-modules")]
+ [EndpointSummary("查询地区功能模块")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetRegionModules(
+ [FromQuery] CatalogQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await catalogQueryService.GetRegionModulesAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
+ cancellationToken));
+ }
+
+ [HttpGet("module-nodes")]
+ [EndpointSummary("查询模块导航节点")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetModuleNodes(
+ [FromQuery] CatalogQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await catalogQueryService.GetModuleNodesAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
+ cancellationToken));
+ }
+
+ [HttpGet("schools")]
+ [EndpointSummary("查询院校目录")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetSchools(
+ [FromQuery] CatalogQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await catalogQueryService.GetSchoolsAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
+ cancellationToken));
+ }
+
+ [HttpGet("majors")]
+ [EndpointSummary("查询专业目录")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetMajors(
+ [FromQuery] CatalogQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await catalogQueryService.GetMajorsAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
+ cancellationToken));
+ }
+
+ [HttpGet("subjects")]
+ [EndpointSummary("查询科目目录")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> 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>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetCategories(
+ [FromQuery] CatalogQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await catalogQueryService.GetCategoriesAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
+ cancellationToken));
+ }
+
+ private async Task 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.")
+ {
+ }
+}
diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
index 8d88cc0..c6b7af6 100644
--- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
+++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
@@ -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
diff --git a/Tiku.Application/Catalog/CatalogQueryModels.cs b/Tiku.Application/Catalog/CatalogQueryModels.cs
new file mode 100644
index 0000000..784a212
--- /dev/null
+++ b/Tiku.Application/Catalog/CatalogQueryModels.cs
@@ -0,0 +1,103 @@
+using System.Text.Json;
+using Tiku.Domain.Catalog;
+
+namespace Tiku.Application.Catalog;
+
+public sealed record CatalogList(IReadOnlyCollection 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);
diff --git a/Tiku.Application/Catalog/ICatalogQueryService.cs b/Tiku.Application/Catalog/ICatalogQueryService.cs
new file mode 100644
index 0000000..534ac9a
--- /dev/null
+++ b/Tiku.Application/Catalog/ICatalogQueryService.cs
@@ -0,0 +1,32 @@
+namespace Tiku.Application.Catalog;
+
+public interface ICatalogQueryService
+{
+ Task> GetRegionsAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetRegionModulesAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetModuleNodesAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetSchoolsAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetMajorsAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetSubjectsAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetCategoriesAsync(
+ CatalogFilter filter,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Infrastructure/Catalog/CatalogQueryService.cs b/Tiku.Infrastructure/Catalog/CatalogQueryService.cs
new file mode 100644
index 0000000..f3afc13
--- /dev/null
+++ b/Tiku.Infrastructure/Catalog/CatalogQueryService.cs
@@ -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> 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(items);
+ }
+
+ public async Task> 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(items);
+ }
+
+ public async Task> 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(items);
+ }
+
+ public async Task> 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(items);
+ }
+
+ public async Task> 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(items);
+ }
+
+ public async Task> 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(items);
+ }
+
+ public async Task> 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(items);
+ }
+
+ private static IQueryable ApplyKeyword(IQueryable query, string? keyword)
+ where T : class
+ {
+ if (string.IsNullOrWhiteSpace(keyword))
+ {
+ return query;
+ }
+
+ var trimmed = keyword.Trim();
+ return query.Where(entity => EF.Property(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);
+ }
+}
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index 31b5bef..06605c8 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -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();
services.AddHttpClient();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs b/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs
new file mode 100644
index 0000000..525c275
--- /dev/null
+++ b/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs
@@ -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®ionId={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 ReadItemsAsync(HttpResponseMessage response)
+ {
+ var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ return body.RootElement
+ .GetProperty("items")
+ .EnumerateArray()
+ .Select(item => item.Clone())
+ .ToArray();
+ }
+}
diff --git a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs
index a3ca4c5..f7303c5 100644
--- a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs
+++ b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs
@@ -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 _));
}
}