fix:修复openapi文档错误
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-03 14:34:23 +08:00
parent 2c4a0bad6c
commit 558b2a4ea8
8 changed files with 121 additions and 13 deletions

View File

@@ -18,9 +18,10 @@ internal static class ApiPresentationExtensions
{
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
options.AddOperationTransformer<PlatformOperationMetadataTransformer>();
options.AddOperationTransformer<AuthenticationOperationTagsTransformer>();
});
services.AddProblemDetails();
return services;
}
}
}

View File

@@ -58,6 +58,7 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
if (realm != AuthRealm.Tenant)
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
@@ -87,6 +88,7 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
var identifier = request.Identifier ?? request.Phone;
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
var result = await authService.LoginWithPasswordAsync(
@@ -114,6 +116,7 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
realm,
@@ -139,6 +142,7 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
realm,
@@ -163,6 +167,7 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
realm,
@@ -406,4 +411,15 @@ public sealed class AuthController(
StringComparison.OrdinalIgnoreCase)))
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
}
}
private void EnsureRouteRealm(AuthRealm realm)
{
var path = Request.Path.Value ?? string.Empty;
var expectedRealm = path.StartsWith("/api/platform/auth/", StringComparison.OrdinalIgnoreCase)
? AuthRealm.Platform
: AuthRealm.Tenant;
if (realm != expectedRealm)
throw new RequiredFieldException(
$"{realm.ToString().ToLowerInvariant()} realm must use the {expectedRealm.ToString().ToLowerInvariant()} authentication route.");
}
}

View File

@@ -57,6 +57,7 @@ public sealed class BrowserAuthController(
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
if (realm != AuthRealm.Tenant)
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken)
@@ -80,6 +81,7 @@ public sealed class BrowserAuthController(
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var identifier = request.Identifier ?? request.Phone;
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest(
@@ -102,6 +104,7 @@ public sealed class BrowserAuthController(
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
@@ -121,6 +124,7 @@ public sealed class BrowserAuthController(
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
@@ -139,6 +143,7 @@ public sealed class BrowserAuthController(
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
@@ -344,6 +349,12 @@ public sealed class BrowserAuthController(
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
private static void EnsureTenantRealm(AuthRealm realm)
{
if (realm != AuthRealm.Tenant)
throw new RequiredFieldException("Browser tenant authentication only accepts the tenant realm.");
}
}
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Tiku.Api.OpenApi;
internal sealed class AuthenticationOperationTagsTransformer : IOpenApiOperationTransformer
{
private static readonly (string Prefix, string Tag)[] AuthenticationTags =
[
("api/platform/auth/", "平台端-认证"),
("api/tenant/auth/browser/", "租户端-浏览器认证"),
("api/tenant/auth/", "租户端-认证"),
("api/student/auth/", "学生端-认证")
];
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
var relativePath = context.Description.RelativePath;
if (relativePath is null) return Task.CompletedTask;
var tag = AuthenticationTags
.FirstOrDefault(candidate => relativePath.StartsWith(candidate.Prefix, StringComparison.OrdinalIgnoreCase))
.Tag;
if (tag is null) return Task.CompletedTask;
operation.Tags ??= new HashSet<OpenApiTagReference>();
operation.Tags.Clear();
operation.Tags.Add(new OpenApiTagReference(tag, null!));
return Task.CompletedTask;
}
}

View File

@@ -17,6 +17,25 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthEndpointTests
{
[Theory]
[InlineData("/api/platform/auth/login/password", AuthRealm.Tenant)]
[InlineData("/api/tenant/auth/login/password", AuthRealm.Platform)]
[InlineData("/api/student/auth/login/password", AuthRealm.Platform)]
public async Task Password_login_route_rejects_a_mismatched_realm(string path, AuthRealm realm)
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.PostAsJsonAsync(path, new PasswordLoginDto
{
Realm = realm,
Identifier = "invalid@example.test",
Password = PasswordTestUserExtensions.TestPassword
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm()
{
@@ -121,13 +140,13 @@ public sealed class AuthEndpointTests
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/tenant/auth"
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/platform/auth"
});
using var client = factory.CreateClient();
var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}";
var requests = new[]
{
new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/login/password")
new HttpRequestMessage(HttpMethod.Post, "/api/platform/auth/login/password")
{
Content = JsonContent.Create(new PasswordLoginDto
{
@@ -136,11 +155,11 @@ public sealed class AuthEndpointTests
Password = PasswordTestUserExtensions.TestPassword
})
},
new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/refresh")
new HttpRequestMessage(HttpMethod.Post, "/api/platform/auth/refresh")
{
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
},
new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/logout")
new HttpRequestMessage(HttpMethod.Post, "/api/platform/auth/logout")
{
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
}
@@ -540,4 +559,4 @@ public sealed class AuthEndpointTests
return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id"));
}
}
}
}

View File

@@ -37,7 +37,7 @@ internal static class AuthenticationTestClientExtensions
{
client.DefaultRequestHeaders.Remove("x-tenant-code");
var response = await client.PostAsJsonAsync(
"/api/tenant/auth/login/password",
"/api/platform/auth/login/password",
new PasswordLoginDto
{
Realm = AuthRealm.Platform,
@@ -110,4 +110,4 @@ internal static class AuthenticationTestClientExtensions
"Authentication response did not contain a refresh token.");
return new TestAuthenticationTokens(accessToken, refreshToken);
}
}
}

View File

@@ -19,6 +19,23 @@ public sealed class OpenApiDocumentationTests
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/public/catalog/regions", out _));
}
[Fact]
public async Task Authentication_operations_are_grouped_by_client_boundary()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/openapi/v1.json");
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var paths = document.RootElement.GetProperty("paths");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("平台端-认证", GetOnlyTag(paths, "/api/platform/auth/login/password"));
Assert.Equal("租户端-认证", GetOnlyTag(paths, "/api/tenant/auth/login/password"));
Assert.Equal("学生端-认证", GetOnlyTag(paths, "/api/student/auth/login/password"));
Assert.Equal("租户端-浏览器认证", GetOnlyTag(paths, "/api/tenant/auth/browser/login/password"));
}
[Fact]
public async Task Platform_operation_metadata_prefers_action_permission_and_exposes_risk()
{
@@ -61,4 +78,14 @@ public sealed class OpenApiDocumentationTests
path.StartsWith("/api/tenant-content", StringComparison.Ordinal) ||
path.StartsWith("/api/tenant-commerce", StringComparison.Ordinal));
}
}
private static string? GetOnlyTag(JsonElement paths, string path)
{
return paths.GetProperty(path)
.GetProperty("post")
.GetProperty("tags")
.EnumerateArray()
.Single()
.GetString();
}
}

View File

@@ -45,7 +45,7 @@ public sealed class PlatformAdminEndpointTests
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode);
targetClient.DefaultRequestHeaders.Authorization = null;
var login = await targetClient.PostAsJsonAsync(
"/api/tenant/auth/login/password",
"/api/platform/auth/login/password",
new PasswordLoginDto
{
Realm = AuthRealm.Platform,
@@ -1041,4 +1041,4 @@ public sealed class PlatformAdminEndpointTests
]);
return (userId, email);
}
}
}