From 41a8a5ff817907f969d4f1b6f7395e6f8f291899 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 13:51:24 +0800 Subject: [PATCH] docs: include dto comments in openapi schemas --- README.md | 11 +-- Tiku.Api/Contracts/AuthDtos.cs | 71 +++++++++++++++++++ Tiku.Api/Contracts/HealthDtos.cs | 6 ++ Tiku.Api/Contracts/TenantDtos.cs | 38 ++++++++++ Tiku.Api/Controllers/MeController.cs | 16 +++++ Tiku.Api/Controllers/TenantsController.cs | 9 +++ Tiku.Api/Tiku.Api.csproj | 2 + Tiku.Application/Auth/AuthContracts.cs | 23 ++++++ Tiku.Application/Tiku.Application.csproj | 2 + .../Api/OpenApiDocumentationTests.cs | 67 +++++++++++++++++ 10 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs diff --git a/README.md b/README.md index eb03478..5fc54fa 100644 --- a/README.md +++ b/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 断言,测试通过后单独提交。 建议下一批迁移顺序: diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs index ec52e07..8bc1ae8 100644 --- a/Tiku.Api/Contracts/AuthDtos.cs +++ b/Tiku.Api/Contracts/AuthDtos.cs @@ -4,74 +4,145 @@ using Tiku.Application.Auth; namespace Tiku.Api.Contracts; +/// +/// 手机号密码登录请求。 +/// public sealed class PasswordLoginDto { + /// + /// 租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。 + /// [Required] [Description("租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。")] public Guid TenantId { get; set; } + /// + /// 手机号,建议前端提交规范化后的中国大陆手机号。 + /// [Required] [StringLength(32)] [Description("手机号,建议前端提交规范化后的中国大陆手机号。")] public string Phone { get; set; } = string.Empty; + /// + /// 用户密码。 + /// [Required] [StringLength(128, MinimumLength = 6)] [Description("用户密码。")] public string Password { get; set; } = string.Empty; } +/// +/// 短信验证码登录请求。 +/// public sealed class SmsLoginDto { + /// + /// 租户 ID。 + /// [Required] [Description("租户 ID。")] public Guid TenantId { get; set; } + /// + /// 中国大陆手机号。 + /// [Required] [StringLength(32)] [Description("中国大陆手机号。")] public string Phone { get; set; } = string.Empty; + /// + /// 收到的短信验证码。 + /// [Required] [StringLength(12, MinimumLength = 4)] [Description("收到的短信验证码。")] public string Code { get; set; } = string.Empty; } +/// +/// OAuth code 登录请求。 +/// public sealed class OAuthCodeDto { + /// + /// 租户 ID。 + /// [Required] [Description("租户 ID。")] public Guid TenantId { get; set; } + /// + /// OAuth 平台返回的一次性授权 code。 + /// [Required] [StringLength(512)] [Description("OAuth 平台返回的一次性授权 code。")] public string Code { get; set; } = string.Empty; + /// + /// 客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。 + /// [Description("客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。")] public Dictionary? Profile { get; set; } + /// + /// 微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。 + /// [StringLength(20)] [Description("微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。")] public string? Lang { get; set; } } +/// +/// refresh token 请求。 +/// public sealed class RefreshSessionDto { + /// + /// refresh token。服务端只存储哈希,明文只返回给客户端一次。 + /// [Required] [StringLength(2048)] [Description("refresh token。服务端只存储哈希,明文只返回给客户端一次。")] public string RefreshToken { get; set; } = string.Empty; } +/// +/// 登录成功后的用户、租户成员和令牌信息。 +/// public sealed class AuthenticatedUserDto { + /// + /// 用户 ID。 + /// public Guid UserId { get; init; } + + /// + /// 手机号。 + /// public string? Phone { get; init; } + + /// + /// 邮箱。 + /// public string? Email { get; init; } + + /// + /// 用户显示名称。 + /// public string? Name { get; init; } + + /// + /// 当前登录租户成员摘要。 + /// public TenantMembershipSummary Tenant { get; init; } = default!; + + /// + /// access token 和 refresh token。 + /// public AuthTokenPair Tokens { get; init; } = default!; public static AuthenticatedUserDto FromApplication(AuthenticatedUser user) diff --git a/Tiku.Api/Contracts/HealthDtos.cs b/Tiku.Api/Contracts/HealthDtos.cs index 832bdf0..ba56f82 100644 --- a/Tiku.Api/Contracts/HealthDtos.cs +++ b/Tiku.Api/Contracts/HealthDtos.cs @@ -1,5 +1,11 @@ namespace Tiku.Api.Contracts; +/// +/// 健康检查响应。 +/// +/// 服务状态,例如 ok。 +/// 服务名称。 +/// 检查时间。 public sealed record HealthResponseDto( string Status, string Service, diff --git a/Tiku.Api/Contracts/TenantDtos.cs b/Tiku.Api/Contracts/TenantDtos.cs index 3426a72..cb48059 100644 --- a/Tiku.Api/Contracts/TenantDtos.cs +++ b/Tiku.Api/Contracts/TenantDtos.cs @@ -6,17 +6,34 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; +/// +/// 租户解析查询参数。 +/// public sealed class TenantResolveQueryDto { + /// + /// 要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。 + /// [StringLength(253)] [Description("要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。")] public string? Host { get; set; } + /// + /// 租户编码;本地开发或无独立域名时使用,例如 master。 + /// [StringLength(100)] [Description("租户编码;本地开发或无独立域名时使用,例如 master。")] public string? TenantCode { get; set; } } +/// +/// 租户解析响应。 +/// +/// 公开租户基础信息。 +/// 公开品牌配置。 +/// 面向前端公开的功能开关。 +/// 面向管理端公开的功能开关。 +/// 可公开的租户配置,不包含密钥或内部配置。 public sealed record TenantResolveResponseDto( PublicTenantDto Tenant, PublicTenantBrandingDto Branding, @@ -24,6 +41,15 @@ public sealed record TenantResolveResponseDto( JsonElement AdminFeatures, JsonElement PublicConfig); +/// +/// 可公开给客户端的租户基础信息。 +/// +/// 租户 ID。 +/// 租户编码。 +/// 租户名称。 +/// 租户状态。 +/// 租户模式。 +/// 当前匹配到的访问域名。 public sealed record PublicTenantDto( Guid Id, string Slug, @@ -32,6 +58,18 @@ public sealed record PublicTenantDto( TenantMode Mode, string? Host); +/// +/// 可公开给客户端的租户品牌信息。 +/// +/// 品牌名称。 +/// 品牌短名称。 +/// 品牌标语。 +/// Logo 地址。 +/// Favicon 地址。 +/// 客服微信。 +/// 公众号或服务号名称。 +/// 公开主题配置。 +/// 公开资源配置。 public sealed record PublicTenantBrandingDto( string? BrandName, string? ShortName, diff --git a/Tiku.Api/Controllers/MeController.cs b/Tiku.Api/Controllers/MeController.cs index 4193797..1e8f4d9 100644 --- a/Tiku.Api/Controllers/MeController.cs +++ b/Tiku.Api/Controllers/MeController.cs @@ -53,6 +53,14 @@ public sealed class MeController( } } +/// +/// 当前用户基础信息和租户成员摘要。 +/// +/// 用户 ID。 +/// 手机号。 +/// 邮箱。 +/// 用户显示名称。 +/// 当前用户拥有的活跃租户成员列表。 public sealed record MeResponse( Guid UserId, string? Phone, @@ -60,6 +68,14 @@ public sealed record MeResponse( string? Name, IReadOnlyCollection Tenants); +/// +/// 用户在某个租户内的成员摘要。 +/// +/// 租户 ID。 +/// 租户名称。 +/// 租户编码。 +/// 当前用户在该租户内的角色。 +/// 租户成员状态。 public sealed record TenantMembershipResponse( Guid TenantId, string TenantName, diff --git a/Tiku.Api/Controllers/TenantsController.cs b/Tiku.Api/Controllers/TenantsController.cs index 2ea0ed1..2ad8e1f 100644 --- a/Tiku.Api/Controllers/TenantsController.cs +++ b/Tiku.Api/Controllers/TenantsController.cs @@ -52,6 +52,15 @@ public sealed class TenantsController( } } +/// +/// 当前请求租户和当前用户在该租户内的权限摘要。 +/// +/// 租户 ID。 +/// 租户名称。 +/// 租户编码。 +/// 租户状态。 +/// 当前用户在租户内的角色。 +/// 当前用户在租户内的权限扩展。 public sealed record CurrentTenantResponse( Guid TenantId, string TenantName, diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj index a8c189d..e36de1c 100644 --- a/Tiku.Api/Tiku.Api.csproj +++ b/Tiku.Api/Tiku.Api.csproj @@ -4,6 +4,8 @@ net10.0 enable enable + true + $(NoWarn);1591 diff --git a/Tiku.Application/Auth/AuthContracts.cs b/Tiku.Application/Auth/AuthContracts.cs index a07b420..0587beb 100644 --- a/Tiku.Application/Auth/AuthContracts.cs +++ b/Tiku.Application/Auth/AuthContracts.cs @@ -2,18 +2,41 @@ using Tiku.Domain.Tenancy; namespace Tiku.Application.Auth; +/// +/// 认证令牌对。 +/// +/// 用于访问 API 的 JWT access token。 +/// 用于刷新会话的 refresh token,服务端只保存哈希。 +/// access token 过期时间。 +/// refresh token 过期时间。 public sealed record AuthTokenPair( string AccessToken, string RefreshToken, DateTimeOffset AccessTokenExpiresAt, DateTimeOffset RefreshTokenExpiresAt); +/// +/// 当前登录租户成员摘要。 +/// +/// 租户 ID。 +/// 租户名称。 +/// 当前用户在租户内的角色。 +/// 租户成员状态。 public sealed record TenantMembershipSummary( Guid TenantId, string TenantName, TenantRole Role, MembershipStatus Status); +/// +/// 登录成功后的应用层用户信息。 +/// +/// 用户 ID。 +/// 手机号。 +/// 邮箱。 +/// 用户显示名称。 +/// 当前登录租户成员摘要。 +/// 认证令牌对。 public sealed record AuthenticatedUser( Guid UserId, string? Phone, diff --git a/Tiku.Application/Tiku.Application.csproj b/Tiku.Application/Tiku.Application.csproj index cceeb12..0d76892 100644 --- a/Tiku.Application/Tiku.Application.csproj +++ b/Tiku.Application/Tiku.Application.csproj @@ -12,6 +12,8 @@ net10.0 enable enable + true + $(NoWarn);1591 diff --git a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs new file mode 100644 index 0000000..a3ca4c5 --- /dev/null +++ b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs @@ -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; + } +}