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;
+ }
+}