forked from gongxuegit/tiku-backend.net
feat: add catalog readonly endpoints
This commit is contained in:
90
Tiku.Api/Contracts/CatalogDtos.cs
Normal file
90
Tiku.Api/Contracts/CatalogDtos.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
145
Tiku.Api/Controllers/CatalogController.cs
Normal file
145
Tiku.Api/Controllers/CatalogController.cs
Normal 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.")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user