diff --git a/server/src/WebDrop.Api/WebDropApp.cs b/server/src/WebDrop.Api/WebDropApp.cs index 46364c4..9d0b7c2 100644 --- a/server/src/WebDrop.Api/WebDropApp.cs +++ b/server/src/WebDrop.Api/WebDropApp.cs @@ -68,7 +68,7 @@ public static class WebDropApp "X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account", "X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain", "X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target") - .WithExposedHeaders("X-WD-Last-Seq") + .WithExposedHeaders("X-WD-Last-Seq", "Retry-After") .SetPreflightMaxAge(TimeSpan.FromHours(1)))); } @@ -76,13 +76,17 @@ public static class WebDropApp { r.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // 限流状态只在内存里,按 IP 分区,不落盘 - r.GlobalLimiter = PartitionedRateLimiter.Create(ctx => - RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.RequestsPerMinute))); + r.GlobalLimiter = PartitionedRateLimiter.Create(ctx => IsFrameUpload(ctx.Request) + ? RateLimitPartition.GetFixedWindowLimiter("frames:" + ClientKey(ctx), _ => PerMinute(options.FrameRequestsPerMinute)) + : RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.RequestsPerMinute))); r.AddPolicy(Endpoints.CreatePolicy, ctx => RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.ChannelCreatesPerMinute))); r.OnRejected = async (context, ct) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + // 告诉客户端多久后窗口重置,弱网和上传重试据此等待,不再盲目重试 + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + context.HttpContext.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); await context.HttpContext.Response.WriteAsJsonAsync( new ErrorBody { Error = ErrorCode.RateLimited.ToWire() }, ApiJson.Default.ErrorBody, cancellationToken: ct); }; @@ -134,6 +138,9 @@ public static class WebDropApp } } + private static bool IsFrameUpload(HttpRequest request) => + HttpMethods.IsPost(request.Method) && request.Path.Value is { } path && path.EndsWith("/frames", StringComparison.Ordinal); + private static string ClientKey(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; private static FixedWindowRateLimiterOptions PerMinute(int permits) => new() diff --git a/server/src/WebDrop.Api/WebDropOptions.cs b/server/src/WebDrop.Api/WebDropOptions.cs index 8ea4ef8..b353b5e 100644 --- a/server/src/WebDrop.Api/WebDropOptions.cs +++ b/server/src/WebDrop.Api/WebDropOptions.cs @@ -21,6 +21,11 @@ public sealed class WebDropOptions public string? PasskeyRpId { get; set; } public int RequestsPerMinute { get; set; } = 600; + /// + /// 上传帧(POST .../frames)单独计数。每帧最多约 1 MiB,和普通 API 请求放在一个 600/分钟的桶里, + /// 持续上传速度就被锁在约 10 MiB/s;帧的总量另有空间配额和单条上限保护。 + /// + public int FrameRequestsPerMinute { get; set; } = 6000; public int ChannelCreatesPerMinute { get; set; } = 30; public int ShareDraftTtlSeconds { get; set; } = 86_400; public int SweepIntervalSeconds { get; set; } = 30;