using System.Buffers.Binary; using System.Net; using System.Net.Sockets; using System.Text; using GatewayKeepalive.Core; namespace GatewayKeepalive.ProbeTests; internal static class Program { private static async Task Main() { var tests = new (string Name, Func Run)[] { ("SOCKS5 分片 IPv4 成功回复", SocksFragmentedIpv4Async), ("SOCKS5 域名与 IPv6 回复", SocksDomainAndIpv6Async), ("SOCKS5 认证与 REP 失败", SocksFailuresAsync), ("SOCKS5 截断回复", SocksTruncatedAsync), ("SOCKS5 慢读取消", SocksSlowReadAsync), ("DNS 压缩 A 记录", DnsCompressedAsync), ("DNS TC 后 TCP 回退", DnsTcpFallbackAsync), ("DNS 畸形响应拒绝", DnsMalformedAsync), ("DNS 事务与来源校验", DnsTransactionAndSourceAsync), ("TLS 客户端配置可进入握手", TlsConfigurationAsync), ("旁路由健康不依赖当前出口", ProxyRecoveryIndependentAsync), ("多目标阈值独立汇总", MultipleTargetThresholdAsync), ("旁路由与出口 DNS 来源分离", DnsServerScopeAsync), ("出口目标按当前网关选择", EgressTargetSelectionAsync), ("非 SOCKS 模式不探测 1070", UseSocksFalseAsync), ("调用方取消向上传播", CallerCancellationAsync), }; var failed = 0; foreach (var test in tests) { try { await test.Run().WaitAsync(TimeSpan.FromSeconds(10)); Console.WriteLine($"PASS {test.Name}"); } catch (Exception ex) { failed++; Console.WriteLine($"FAIL {test.Name}: {ex.GetType().Name}: {ex.Message}"); } } Console.WriteLine($"{tests.Length - failed}/{tests.Length} tests passed"); return failed == 0 ? 0 : 1; } private static async Task SocksFragmentedIpv4Async() { var reply = new byte[] { 5, 0, 5, 0, 0, 1, 10, 20, 30, 40, 0x1f, 0x90, 0xaa }; using var stream = new ScriptedStream(reply, fragment: true); await Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None); Assert.Equal(12, stream.Consumed, "回复必须精确消费到 BND.PORT,不能过读。"); var one = new byte[1]; var read = await stream.ReadAsync(one.AsMemory(), CancellationToken.None); Assert.Equal(1, read, "尾随字节应仍可由调用方读取。"); Assert.Equal((byte)0xaa, one[0], "尾随字节内容被改变。"); var request = stream.Written.Skip(3).ToArray(); Assert.SequenceEqual(new byte[] { 5, 1, 0, 1, 1, 2, 3, 4, 0x01, 0xbb }, request, "IPv4 CONNECT 请求编码错误。"); } private static async Task SocksDomainAndIpv6Async() { byte[] domainReply = [ 5, 0, 5, 0, 0, 3, 11, .. Encoding.ASCII.GetBytes("example.com"), 0, 80 ]; using (var domainStream = new ScriptedStream(domainReply, fragment: true)) { await Socks5Probe.ConnectAsync(domainStream, "example.com", 443, CancellationToken.None); var request = domainStream.Written.Skip(3).ToArray(); Assert.Equal(3, request[3], "域名 CONNECT 必须使用 ATYP=3。"); Assert.Equal(11, request[4], "域名长度编码错误。"); Assert.Equal("example.com", Encoding.ASCII.GetString(request, 5, 11), "域名编码错误。"); } var ipv6 = IPAddress.Parse("2001:db8::1").GetAddressBytes(); byte[] ipv6Reply = [ 5, 0, 5, 0, 0, 4, .. ipv6, 0x01, 0xbb ]; using var ipv6Stream = new ScriptedStream(ipv6Reply, fragment: true); await Socks5Probe.ConnectAsync(ipv6Stream, "2001:db8::1", 443, CancellationToken.None); Assert.Equal(4, ipv6Stream.Written[6], "IPv6 CONNECT 必须使用 ATYP=4。"); Assert.Equal(ipv6Stream.Consumed, ipv6Reply.Length, "IPv6 回复未完整消费。"); } private static async Task SocksFailuresAsync() { using (var auth = new ScriptedStream([5, 2], fragment: true)) { var ex = await Assert.ThrowsAsync(() => Socks5Probe.ConnectAsync(auth, "example.com", 443, CancellationToken.None)); Assert.Equal((byte)2, ex.ReplyCode, "认证方法错误码未被保留。"); } var rejectedReply = new byte[] { 5, 0, 5, 5, 0, 3, 4, (byte)'h', (byte)'o', (byte)'s', (byte)'t', 0, 80 }; using var rejected = new ScriptedStream(rejectedReply, fragment: true); var rejectedEx = await Assert.ThrowsAsync(() => Socks5Probe.ConnectAsync(rejected, "example.com", 443, CancellationToken.None)); Assert.Equal((byte)5, rejectedEx.ReplyCode, "REP 错误码未被保留。"); Assert.Equal(rejectedReply.Length, rejected.Consumed, "REP 失败时也必须消费完整变长回复。"); } private static async Task SocksTruncatedAsync() { using var stream = new ScriptedStream([5, 0, 5, 0, 0, 1, 127, 0], fragment: true); await Assert.ThrowsAsync(() => Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None)); } private static async Task SocksSlowReadAsync() { using var stream = new ScriptedStream([5, 0], fragment: true, delayMs: 1000); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80)); await Assert.ThrowsAsync(() => Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, cts.Token)); } private static async Task DnsCompressedAsync() { using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var server = ServeOnceAsync(udp, cts.Token, request => { var (id, host) = ReadQuery(request); return BuildDnsResponse(id, host, IPAddress.Parse("203.0.113.9"), compressed: true); }); var addresses = await DnsProbe.QueryAAsync( IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token); await server; Assert.Equal(1, addresses.Length, "A 记录数量错误。"); Assert.Equal("203.0.113.9", addresses[0].ToString(), "压缩名称后的 A 地址错误。"); } private static async Task DnsTcpFallbackAsync() { using var tcp = new TcpListener(IPAddress.Loopback, 0); tcp.Start(); tcp.Server.ReceiveTimeout = 3000; var port = ((IPEndPoint)tcp.LocalEndpoint).Port; using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var udpServer = ServeOnceAsync(udp, cts.Token, request => { var (id, host) = ReadQuery(request); return BuildDnsResponse(id, host, IPAddress.Any, compressed: false, truncated: true); }); var tcpServer = ServeDnsTcpOnceAsync(tcp, cts.Token); var addresses = await DnsProbe.QueryAAsync( IPAddress.Loopback, port, "fallback.test", IPAddress.Loopback, cts.Token); await Task.WhenAll(udpServer, tcpServer); Assert.Equal("198.51.100.7", addresses.Single().ToString(), "TCP 回退地址错误。"); } private static async Task DnsMalformedAsync() { using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var server = ServeOnceAsync(udp, cts.Token, request => { var (id, _) = ReadQuery(request); var malformed = BuildDnsResponse(id, "other.example", IPAddress.Parse("203.0.113.8"), compressed: true); BinaryPrimitives.WriteUInt16BigEndian(malformed.AsSpan(6, 2), 2); return malformed; }); await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); await server; var loopResponse = new byte[18]; BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(0, 2), 42); BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(2, 2), 0x8180); BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(4, 2), 1); loopResponse[12] = 0xc0; loopResponse[13] = 12; Assert.Throws(() => DnsProbe.ParseResponse(loopResponse, 42, "example.com")); } private static async Task DnsTransactionAndSourceAsync() { using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0))) { var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var server = ServeOnceAsync(udp, cts.Token, request => { var (id, host) = ReadQuery(request); return BuildDnsResponse( (ushort)(id + 1), host, IPAddress.Parse("203.0.113.10"), compressed: true); }); await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); await server; } using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0))) { var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var server = ServeFromWrongSourceAsync(udp, cts.Token); await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); await server; } } private static async Task UseSocksFalseAsync() { var settings = new AppSettings { ProxyDns = "192.0.2.1", UseSocks = false, TestHosts = ["127.0.0.1"], RequiredHealthyTargets = 1, DirectTestHosts = ["127.0.0.9"], RequiredDirectHealthyTargets = 1, ProbeTimeoutMs = 150, RoundTimeoutMs = 600, }; var snapshot = LoopbackSnapshot(source: null); var probe = new NetworkHealthProbe((_, _, _) => false); var report = await probe.CheckAsync(settings, snapshot, CancellationToken.None); Assert.Equal(3, report.Checks.Length, "非 SOCKS 模式应只有连接、DNS、出口三项。"); Assert.True(report.Checks.All(x => x.Name != "SOCKS5"), "非 SOCKS 模式不应要求 1070。"); Assert.True(report.Checks.First(x => x.Name == "旁路由 DNS").Success, "IPv4 字面量目标不应因缺少系统 DNS 而失败。"); Assert.True(report.ProxyHealthy, "未启用 SOCKS 时可选 TCP53 诊断失败不能否定旁路由 DNS 候选。"); Assert.False(report.InternetHealthy, "无本地 TLS 服务时出口不应被判为健康。"); Assert.True( report.Checks.First(x => x.Name == "当前出口").Detail.Contains("127.0.0.9"), "直连出口必须使用 DirectTestHosts,而不是代理 TestHosts。"); } private static async Task TlsConfigurationAsync() { using var stream = new ScriptedStream([], fragment: true); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); try { await NetworkHealthProbe.AuthenticateTlsAsync(stream, "localhost", cts.Token); throw new InvalidOperationException("空流不应完成 TLS 握手。"); } catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException) { Assert.False( ex is ArgumentException or InvalidOperationException, $"TLS 配置失败,而不是正常的握手读取失败:{ex.Message}"); } } private static Task ProxyRecoveryIndependentAsync() { var checks = new[] { new ProbeResult("旁路由连接", true, 1, "可达"), new ProbeResult("旁路由 DNS", true, 1, "解析成功"), new ProbeResult("SOCKS5", true, 1, "CONNECT 成功"), new ProbeResult("当前出口", false, 1, "当前直连出口失败") }; var probeOnly = NetworkHealthProbe.AssembleHealthReport( checks, proxyTcpSuccess: true, proxyDnsSuccess: true, socksSuccess: true, useSocks: true, healthyTargets: 0, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); Assert.True(probeOnly.ProxyHealthy, "旁路由候选层通过时必须可判定恢复。"); Assert.False(probeOnly.InternetHealthy, "当前出口失败必须单独反映。"); var noSocks = NetworkHealthProbe.AssembleHealthReport( checks, proxyTcpSuccess: false, proxyDnsSuccess: true, socksSuccess: null, useSocks: false, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); Assert.True(noSocks.ProxyHealthy, "未启用 SOCKS 时 TCP53 失败不应否定成功的旁路由 DNS。"); Assert.True(noSocks.InternetHealthy, "当前出口成功应独立成立。"); var internetOnly = NetworkHealthProbe.AssembleHealthReport( checks, proxyTcpSuccess: false, proxyDnsSuccess: false, socksSuccess: false, useSocks: true, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); Assert.False(internetOnly.ProxyHealthy, "旁路由候选失败时不能误报恢复。"); Assert.True(internetOnly.InternetHealthy, "当前出口成功不应被候选失败覆盖。"); return Task.CompletedTask; } private static Task MultipleTargetThresholdAsync() { Assert.True(NetworkHealthProbe.MeetsHealthyTargets(1, 1), "1/3 目标成功且要求 1 时应通过。"); Assert.True(NetworkHealthProbe.MeetsHealthyTargets(2, 2), "2/3 目标成功且要求 2 时应通过。"); Assert.False(NetworkHealthProbe.MeetsHealthyTargets(1, 2), "仅 1/3 成功不应满足要求 2。"); Assert.False(NetworkHealthProbe.MeetsHealthyTargets(0, 1), "全部失败不应满足要求 1。"); return Task.CompletedTask; } private static Task DnsServerScopeAsync() { var settings = new AppSettings { ProxyDns = "192.168.6.2" }; var snapshot = new NetworkSnapshot( new AdapterInfo(Guid.NewGuid(), 1, "Ethernet", true, [], 1, "scope-test"), [], DnsConfiguration.Auto, ["192.168.6.1", "192.168.6.1", "2001:db8::53", "not-an-ip"], DateTimeOffset.UtcNow); var proxy = NetworkHealthProbe.ProxyDnsServers(settings); var effective = NetworkHealthProbe.EffectiveDnsServers(snapshot); Assert.Equal(1, proxy.Length, "旁路由 DNS 只应取 ProxyDns。"); Assert.Equal("192.168.6.2", proxy[0].ToString(), "旁路由 DNS 服务器错误。"); Assert.Equal(1, effective.Length, "出口 DNS 应去重并忽略非 IPv4。"); Assert.Equal("192.168.6.1", effective[0].ToString(), "出口 DNS 必须取快照 EffectiveDns。"); Assert.True(proxy.All(x => !effective.Contains(x)), "两套 DNS 列表不得混用。"); return Task.CompletedTask; } private static Task EgressTargetSelectionAsync() { var settings = new AppSettings { TestHosts = ["www.google.com"], RequiredHealthyTargets = 1, DirectTestHosts = ["www.baidu.com", "www.microsoft.com"], RequiredDirectHealthyTargets = 1, }; var proxy = NetworkHealthProbe.SelectEgressTargets( settings, LoopbackSnapshot(source: null, settings.ProxyGateway)); Assert.True(proxy.IsProxy, "旁路由网关必须选择代理目标。"); Assert.Equal("www.google.com", string.Join(',', proxy.Hosts), "旁路由出口目标错误。"); Assert.Equal(1, proxy.RequiredHealthyTargets, "旁路由出口阈值错误。"); var direct = NetworkHealthProbe.SelectEgressTargets( settings, LoopbackSnapshot(source: null, settings.DirectGateway)); Assert.False(direct.IsProxy, "主路由网关必须选择直连目标。"); Assert.Equal( "www.baidu.com,www.microsoft.com", string.Join(',', direct.Hosts), "直连出口不能继续使用 www.google.com。"); Assert.Equal(1, direct.RequiredHealthyTargets, "直连出口阈值错误。"); var external = NetworkHealthProbe.SelectEgressTargets( settings, LoopbackSnapshot(source: null, "203.0.113.1")); Assert.False(external.IsProxy, "非旁路由网关应按当前直连出口验证。"); Assert.Equal( "www.baidu.com,www.microsoft.com", string.Join(',', external.Hosts), "外部网关不应回退到代理目标。"); return Task.CompletedTask; } private static async Task CallerCancellationAsync() { var settings = new AppSettings { ProxyDns = "192.0.2.1", UseSocks = false, TestHosts = ["127.0.0.1"], ProbeTimeoutMs = 500, RoundTimeoutMs = 1000, }; using var cts = new CancellationTokenSource(); cts.Cancel(); var probe = new NetworkHealthProbe(); await Assert.ThrowsAsync(() => probe.CheckAsync(settings, LoopbackSnapshot(source: null), cts.Token)); } private static NetworkSnapshot LoopbackSnapshot( string? source, string gateway = "127.0.0.1") => new( new AdapterInfo( Guid.NewGuid(), 1, "Loopback", true, source is null ? [] : [new Ipv4Address(source, 8)], 1, "loopback"), [new RouteEntry(gateway, 1, 0, 1)], DnsConfiguration.Auto, [], DateTimeOffset.UtcNow); private static async Task ServeOnceAsync( UdpClient server, CancellationToken ct, Func responseFactory) { var request = await server.ReceiveAsync(ct); var response = responseFactory(request.Buffer); await server.SendAsync(response, request.RemoteEndPoint, ct); } private static async Task ServeFromWrongSourceAsync(UdpClient server, CancellationToken ct) { var request = await server.ReceiveAsync(ct); var (id, host) = ReadQuery(request.Buffer); var response = BuildDnsResponse( id, host, IPAddress.Parse("203.0.113.11"), compressed: true); using var sender = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); await sender.SendAsync(response, request.RemoteEndPoint, ct); } private static async Task ServeDnsTcpOnceAsync(TcpListener listener, CancellationToken ct) { using var client = await listener.AcceptTcpClientAsync(ct); await using var stream = client.GetStream(); var prefix = new byte[2]; await ReadExactlyAsync(stream, prefix, ct); var length = BinaryPrimitives.ReadUInt16BigEndian(prefix); var request = new byte[length]; await ReadExactlyAsync(stream, request, ct); var (id, host) = ReadQuery(request); var response = BuildDnsResponse( id, host, IPAddress.Parse("198.51.100.7"), compressed: true); BinaryPrimitives.WriteUInt16BigEndian(prefix, (ushort)response.Length); await stream.WriteAsync(prefix, ct); var split = Math.Max(1, response.Length / 3); await stream.WriteAsync(response.AsMemory(0, split), ct); await Task.Delay(10, ct); await stream.WriteAsync(response.AsMemory(split, response.Length - split), ct); await stream.FlushAsync(ct); } private static (ushort Id, string Host) ReadQuery(byte[] query) { var id = BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(0, 2)); var offset = 12; var labels = new List(); while (query[offset] != 0) { var length = query[offset++]; labels.Add(Encoding.ASCII.GetString(query, offset, length)); offset += length; } return (id, string.Join('.', labels)); } private static byte[] BuildDnsResponse( ushort id, string host, IPAddress answer, bool compressed, bool truncated = false) { var question = EncodeName(host); var answerCount = truncated ? 0 : 1; var answerLength = truncated ? 0 : (compressed ? 16 : question.Length + 14); var response = new byte[12 + question.Length + 4 + answerLength]; BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(0, 2), id); BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(2, 2), truncated ? (ushort)0x8380 : (ushort)0x8180); BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(4, 2), 1); BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(6, 2), (ushort)answerCount); question.CopyTo(response, 12); var offset = 12 + question.Length; BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1); BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1); offset += 4; if (truncated) return response; if (compressed) { response[offset++] = 0xc0; response[offset++] = 12; } else { question.CopyTo(response, offset); offset += question.Length; } BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1); BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1); BinaryPrimitives.WriteUInt32BigEndian(response.AsSpan(offset + 4, 4), 60); BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 8, 2), 4); answer.GetAddressBytes().CopyTo(response, offset + 10); return response; } private static byte[] EncodeName(string host) { var labels = host.Split('.'); var result = new List(); foreach (var label in labels) { var bytes = Encoding.ASCII.GetBytes(label); result.Add((byte)bytes.Length); result.AddRange(bytes); } result.Add(0); return result.ToArray(); } private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) { var read = 0; while (read < buffer.Length) { var count = await stream.ReadAsync(buffer[read..], ct); if (count == 0) throw new EndOfStreamException(); read += count; } } private sealed class ScriptedStream : Stream { private readonly byte[] _incoming; private readonly bool _fragment; private readonly int _delayMs; private int _position; public ScriptedStream(byte[] incoming, bool fragment, int delayMs = 0) { _incoming = incoming; _fragment = fragment; _delayMs = delayMs; } public int Consumed => _position; public List Written { get; } = []; public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => true; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { if (_delayMs > 0) await Task.Delay(_delayMs, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (_position >= _incoming.Length || buffer.Length == 0) return 0; var count = _fragment ? 1 : Math.Min(buffer.Length, _incoming.Length - _position); _incoming.AsMemory(_position, count).CopyTo(buffer); _position += count; return count; } public override ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Written.AddRange(buffer.ToArray()); return ValueTask.CompletedTask; } public override int Read(byte[] buffer, int offset, int count) { if (_position >= _incoming.Length) return 0; var length = Math.Min(count, _incoming.Length - _position); Array.Copy(_incoming, _position, buffer, offset, length); _position += length; return length; } public override void Write(byte[] buffer, int offset, int count) => Written.AddRange(buffer.AsSpan(offset, count).ToArray()); public override void Flush() { } public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } private static class Assert { public static void True(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } public static void False(bool condition, string message) => True(!condition, message); public static void Equal(T expected, T actual, string message) where T : IEquatable { if (!expected.Equals(actual)) throw new InvalidOperationException($"{message} 期望 {expected},实际 {actual}。"); } public static void SequenceEqual(byte[] expected, byte[] actual, string message) { if (!expected.AsSpan().SequenceEqual(actual)) throw new InvalidOperationException( $"{message} 期望 {Convert.ToHexString(expected)},实际 {Convert.ToHexString(actual)}。"); } public static async Task ThrowsAsync(Func action) where T : Exception { try { await action(); } catch (T ex) { return ex; } catch (Exception ex) { throw new InvalidOperationException( $"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex); } throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。"); } public static void Throws(Action action) where T : Exception { try { action(); } catch (T) { return; } catch (Exception ex) { throw new InvalidOperationException( $"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex); } throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。"); } } }