docs: include dto comments in openapi schemas
This commit is contained in:
11
README.md
11
README.md
@@ -236,11 +236,12 @@ dotnet ef migrations script \
|
||||
后续从旧 Nest/Supabase 迁移 API 时,优先按当前认证和租户接口的模板推进:
|
||||
|
||||
1. DTO 放到 `Tiku.Api/Contracts`,只包含 HTTP 输入输出形状、校验属性和 OpenAPI 描述。
|
||||
2. Controller 保持轻量,只做路由、授权、DTO 到 Application request 的映射。
|
||||
3. 业务流程放到 `Tiku.Application`,EF/外部服务实现放到 `Tiku.Infrastructure`。
|
||||
4. 已登录业务接口默认从 `ICurrentTenant` / `ICurrentUser` 取上下文,不直接信任 body 里的 `tenantId`。
|
||||
5. 公开接口只返回 branding、feature flags、public config 等可暴露字段,不泄露 secret/refund/payment/internal metadata。
|
||||
6. 每迁一个小闭环就补集成测试和 Scalar/OpenAPI 描述,测试通过后单独提交。
|
||||
2. Request / Response DTO 字段必须写 XML documentation comments;API 项目生成 XML 文档,内置 OpenAPI 会把注释带到 Scalar schema。
|
||||
3. Controller 保持轻量,只做路由、授权、DTO 到 Application request 的映射。
|
||||
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 断言,测试通过后单独提交。
|
||||
|
||||
建议下一批迁移顺序:
|
||||
|
||||
|
||||
@@ -4,74 +4,145 @@ using Tiku.Application.Auth;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号密码登录请求。
|
||||
/// </summary>
|
||||
public sealed class PasswordLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Description("租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号,建议前端提交规范化后的中国大陆手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
[Description("手机号,建议前端提交规范化后的中国大陆手机号。")]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用户密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 6)]
|
||||
[Description("用户密码。")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码登录请求。
|
||||
/// </summary>
|
||||
public sealed class SmsLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Description("租户 ID。")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 中国大陆手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
[Description("中国大陆手机号。")]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 收到的短信验证码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(12, MinimumLength = 4)]
|
||||
[Description("收到的短信验证码。")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OAuth code 登录请求。
|
||||
/// </summary>
|
||||
public sealed class OAuthCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Description("租户 ID。")]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 平台返回的一次性授权 code。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(512)]
|
||||
[Description("OAuth 平台返回的一次性授权 code。")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。
|
||||
/// </summary>
|
||||
[Description("客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。")]
|
||||
public Dictionary<string, object?>? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。
|
||||
/// </summary>
|
||||
[StringLength(20)]
|
||||
[Description("微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。")]
|
||||
public string? Lang { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// refresh token 请求。
|
||||
/// </summary>
|
||||
public sealed class RefreshSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// refresh token。服务端只存储哈希,明文只返回给客户端一次。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
[Description("refresh token。服务端只存储哈希,明文只返回给客户端一次。")]
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登录成功后的用户、租户成员和令牌信息。
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedUserDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户显示名称。
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前登录租户成员摘要。
|
||||
/// </summary>
|
||||
public TenantMembershipSummary Tenant { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// access token 和 refresh token。
|
||||
/// </summary>
|
||||
public AuthTokenPair Tokens { get; init; } = default!;
|
||||
|
||||
public static AuthenticatedUserDto FromApplication(AuthenticatedUser user)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 健康检查响应。
|
||||
/// </summary>
|
||||
/// <param name="Status">服务状态,例如 ok。</param>
|
||||
/// <param name="Service">服务名称。</param>
|
||||
/// <param name="CheckedAt">检查时间。</param>
|
||||
public sealed record HealthResponseDto(
|
||||
string Status,
|
||||
string Service,
|
||||
|
||||
@@ -6,17 +6,34 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 租户解析查询参数。
|
||||
/// </summary>
|
||||
public sealed class TenantResolveQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。
|
||||
/// </summary>
|
||||
[StringLength(253)]
|
||||
[Description("要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。")]
|
||||
public string? Host { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码;本地开发或无独立域名时使用,例如 master。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("租户编码;本地开发或无独立域名时使用,例如 master。")]
|
||||
public string? TenantCode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 租户解析响应。
|
||||
/// </summary>
|
||||
/// <param name="Tenant">公开租户基础信息。</param>
|
||||
/// <param name="Branding">公开品牌配置。</param>
|
||||
/// <param name="Features">面向前端公开的功能开关。</param>
|
||||
/// <param name="AdminFeatures">面向管理端公开的功能开关。</param>
|
||||
/// <param name="PublicConfig">可公开的租户配置,不包含密钥或内部配置。</param>
|
||||
public sealed record TenantResolveResponseDto(
|
||||
PublicTenantDto Tenant,
|
||||
PublicTenantBrandingDto Branding,
|
||||
@@ -24,6 +41,15 @@ public sealed record TenantResolveResponseDto(
|
||||
JsonElement AdminFeatures,
|
||||
JsonElement PublicConfig);
|
||||
|
||||
/// <summary>
|
||||
/// 可公开给客户端的租户基础信息。
|
||||
/// </summary>
|
||||
/// <param name="Id">租户 ID。</param>
|
||||
/// <param name="Slug">租户编码。</param>
|
||||
/// <param name="Name">租户名称。</param>
|
||||
/// <param name="Status">租户状态。</param>
|
||||
/// <param name="Mode">租户模式。</param>
|
||||
/// <param name="Host">当前匹配到的访问域名。</param>
|
||||
public sealed record PublicTenantDto(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
@@ -32,6 +58,18 @@ public sealed record PublicTenantDto(
|
||||
TenantMode Mode,
|
||||
string? Host);
|
||||
|
||||
/// <summary>
|
||||
/// 可公开给客户端的租户品牌信息。
|
||||
/// </summary>
|
||||
/// <param name="BrandName">品牌名称。</param>
|
||||
/// <param name="ShortName">品牌短名称。</param>
|
||||
/// <param name="Slogan">品牌标语。</param>
|
||||
/// <param name="LogoUrl">Logo 地址。</param>
|
||||
/// <param name="FaviconUrl">Favicon 地址。</param>
|
||||
/// <param name="ServiceWechat">客服微信。</param>
|
||||
/// <param name="ServiceAccountName">公众号或服务号名称。</param>
|
||||
/// <param name="Theme">公开主题配置。</param>
|
||||
/// <param name="PublicAssets">公开资源配置。</param>
|
||||
public sealed record PublicTenantBrandingDto(
|
||||
string? BrandName,
|
||||
string? ShortName,
|
||||
|
||||
@@ -53,6 +53,14 @@ public sealed class MeController(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户基础信息和租户成员摘要。
|
||||
/// </summary>
|
||||
/// <param name="UserId">用户 ID。</param>
|
||||
/// <param name="Phone">手机号。</param>
|
||||
/// <param name="Email">邮箱。</param>
|
||||
/// <param name="Name">用户显示名称。</param>
|
||||
/// <param name="Tenants">当前用户拥有的活跃租户成员列表。</param>
|
||||
public sealed record MeResponse(
|
||||
Guid UserId,
|
||||
string? Phone,
|
||||
@@ -60,6 +68,14 @@ public sealed record MeResponse(
|
||||
string? Name,
|
||||
IReadOnlyCollection<TenantMembershipResponse> Tenants);
|
||||
|
||||
/// <summary>
|
||||
/// 用户在某个租户内的成员摘要。
|
||||
/// </summary>
|
||||
/// <param name="TenantId">租户 ID。</param>
|
||||
/// <param name="TenantName">租户名称。</param>
|
||||
/// <param name="TenantSlug">租户编码。</param>
|
||||
/// <param name="Role">当前用户在该租户内的角色。</param>
|
||||
/// <param name="Status">租户成员状态。</param>
|
||||
public sealed record TenantMembershipResponse(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
|
||||
@@ -52,6 +52,15 @@ public sealed class TenantsController(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户和当前用户在该租户内的权限摘要。
|
||||
/// </summary>
|
||||
/// <param name="TenantId">租户 ID。</param>
|
||||
/// <param name="TenantName">租户名称。</param>
|
||||
/// <param name="TenantSlug">租户编码。</param>
|
||||
/// <param name="Status">租户状态。</param>
|
||||
/// <param name="Role">当前用户在租户内的角色。</param>
|
||||
/// <param name="Permissions">当前用户在租户内的权限扩展。</param>
|
||||
public sealed record CurrentTenantResponse(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,18 +2,41 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 认证令牌对。
|
||||
/// </summary>
|
||||
/// <param name="AccessToken">用于访问 API 的 JWT access token。</param>
|
||||
/// <param name="RefreshToken">用于刷新会话的 refresh token,服务端只保存哈希。</param>
|
||||
/// <param name="AccessTokenExpiresAt">access token 过期时间。</param>
|
||||
/// <param name="RefreshTokenExpiresAt">refresh token 过期时间。</param>
|
||||
public sealed record AuthTokenPair(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
DateTimeOffset RefreshTokenExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// 当前登录租户成员摘要。
|
||||
/// </summary>
|
||||
/// <param name="TenantId">租户 ID。</param>
|
||||
/// <param name="TenantName">租户名称。</param>
|
||||
/// <param name="Role">当前用户在租户内的角色。</param>
|
||||
/// <param name="Status">租户成员状态。</param>
|
||||
public sealed record TenantMembershipSummary(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
TenantRole Role,
|
||||
MembershipStatus Status);
|
||||
|
||||
/// <summary>
|
||||
/// 登录成功后的应用层用户信息。
|
||||
/// </summary>
|
||||
/// <param name="UserId">用户 ID。</param>
|
||||
/// <param name="Phone">手机号。</param>
|
||||
/// <param name="Email">邮箱。</param>
|
||||
/// <param name="Name">用户显示名称。</param>
|
||||
/// <param name="Tenant">当前登录租户成员摘要。</param>
|
||||
/// <param name="Tokens">认证令牌对。</param>
|
||||
public sealed record AuthenticatedUser(
|
||||
Guid UserId,
|
||||
string? Phone,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
67
Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs
Normal file
67
Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class OpenApiDocumentationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Openapi_includes_request_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(
|
||||
"租户 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user