feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,124 @@
using PCL.Core.IO.Storage.Cache;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http.Cache;
/// <summary>
/// HTTP 缓存处理器
/// </summary>
public class HttpCacheHandler : DelegatingHandler
{
private ICacheService _cacheService;
public HttpCacheHandler(HttpMessageHandler invoker, ICacheService cacheService)
{
InnerHandler = invoker;
_cacheService = cacheService;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var uri = request.RequestUri?.ToString();
if (string.IsNullOrEmpty(uri))
{
return await base.SendAsync(request, ct).ConfigureAwait(false);
}
// seek cache
var cacheKey = CacheKeys.ApiResponse("http", uri);
var cached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
if (cached.Found)
{
var cachedResponse = _DeserializeResponse(cached.Value!);
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
cachedResponse.RequestMessage = request;
return cachedResponse;
}
// seek metadata
var metaKey = CacheKeys.ApiResponseMeta("http", uri);
var metaCached = await _cacheService.GetAsync<HttpCacheMetadata>(metaKey, ct).ConfigureAwait(false);
if (metaCached.Found)
{
if (metaCached.Value!.ETag is not null)
{
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(metaCached.Value!.ETag));
}
if (metaCached.Value!.LastModified is not null)
{
request.Headers.IfModifiedSince = DateTimeOffset.Parse(metaCached.Value!.LastModified);
}
}
// send request
var response = await base.SendAsync(request, ct).ConfigureAwait(false);
if (response.Headers.CacheControl?.NoStore ?? false)
{
return response;
}
// not modified, return cached response if exists
if (response.StatusCode is HttpStatusCode.NotModified)
{
var reCached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
if (reCached.Found)
{
var cachedResponse = _DeserializeResponse(reCached.Value!);
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
cachedResponse.RequestMessage = request;
return cachedResponse;
}
}
// cache response
var body = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
var ttl = _ComputeTtl(response);
await _cacheService.SetAsync(cacheKey, body, new CachePolicy
{
AbsoluteExpiration = ttl,
Group = "http",
Tags = "http-response"
}, ct).ConfigureAwait(false);
await _cacheService.SetAsync(metaKey, new HttpCacheMetadata
{
ETag = response.Headers.ETag?.Tag,
LastModified = response.Content.Headers.LastModified?.ToString("O"),
StatusCode = (int)response.StatusCode
}, CachePolicy.NeverExpire, ct).ConfigureAwait(false);
// not matched, return original response
response.Headers.Add("X-Cache-Hit", "MISS");
return response;
}
private static TimeSpan? _ComputeTtl(HttpResponseMessage response)
{
var cc = response.Headers.CacheControl;
if (cc?.MaxAge is not null)
{
return cc.MaxAge;
}
if (cc?.SharedMaxAge is not null)
{
return cc.SharedMaxAge;
}
return TimeSpan.FromMinutes(5);
}
private static HttpResponseMessage _DeserializeResponse(byte[] data)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(data)
};
}
}
@@ -0,0 +1,8 @@
namespace PCL.Core.IO.Net.Http.Cache;
public record HttpCacheMetadata
{
public string? ETag { get; init; }
public string? LastModified { get; init; }
public int StatusCode { get; init; }
}
@@ -0,0 +1,248 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.IO.Net.Dns;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
namespace PCL.Core.IO.Net.Http;
public class HostConnectionHandler
{
public static HostConnectionHandler Instance { get; } = new();
private const string ModuleName = "HostConnectionHandler";
private readonly DnsQuery _dnsQuery = DnsQuery.Instance;
private static readonly TimeSpan _CacheDuration = TimeSpan.FromMinutes(10); // 10 分钟缓存(RFC 建议值)
private const int WaitTasks = 2;
// 缓存结构: (host, port) -> (IPAddress -> LastSuccessUtc)
private readonly ConcurrentDictionary<(string host, int port), ConcurrentDictionary<IPAddress, DateTime>>
_connectionCache = new();
public async ValueTask<Stream> GetConnectionAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
var host = context.DnsEndPoint.Host;
var port = context.DnsEndPoint.Port;
var now = DateTime.UtcNow;
// 代理地址或者直连 IP 直接返回结果
if (IPAddress.TryParse(host, out var directIp))
{
return await _ConnectToAddressAsync(directIp, port, cancellationToken).ConfigureAwait(false);
}
var addresses = await _dnsQuery.QueryForIpAsync(host, cancellationToken).ConfigureAwait(false);
if (addresses is null || addresses.Length == 0)
{
throw new HttpRequestException($"DNS resolution failed for {host}");
}
// 对待连接地址进行排序 (缓存成功+IPv6优先)
var sortedAddresses = _SortAddresses(host, port, addresses, now);
// Happy Eyeballs 连接逻辑,优先 IPv6 稍后
var connectionTasks = new List<Task<NetworkStream>>(WaitTasks);
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var connectCancellationToken = cancellationSource.Token;
try
{
for (int i = 0; i < sortedAddresses.Length; i++)
{
var address = sortedAddresses[i];
var delayMs = i * (address.AddressFamily.Equals(AddressFamily.InterNetwork) ? 150 : 80); // 偏向使用 v6
connectionTasks.Add(_DelayedConnectAsync(address, port, delayMs, connectCancellationToken));
}
// 等待首个成功连接
var winner = await connectionTasks
.ToArray()
.WhenAnySuccessAsync()
.ConfigureAwait(false);
var stream = await winner.ConfigureAwait(false);
// 更新缓存 + 记录成功
var remoteIp = ((IPEndPoint)stream.Socket.RemoteEndPoint!).Address;
_UpdateConnectionCache(host, port, remoteIp, now);
LogWrapper.Debug(ModuleName, $"Connected to {host} via {remoteIp}");
// 取消其他连接
// ReSharper disable once MethodHasAsyncOverload
cancellationSource.Cancel();
_ = _CleanupUnusedConnectionsAsync(connectionTasks, winner).ConfigureAwait(false);
return stream;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 清理所有连接
// ReSharper disable once MethodHasAsyncOverload
cancellationSource.Cancel();
_ = _CleanupUnusedConnectionsAsync(connectionTasks, null).ConfigureAwait(false);
LogWrapper.Error(ex, ModuleName, $"All connection attempts failed for {host}");
throw new HttpRequestException($"Connection failed for {host}", ex);
}
finally
{
cancellationSource.Dispose();
}
}
private IPAddress[] _SortAddresses(string host, int port, IPAddress[] addresses, DateTime now)
{
var cacheKey = (host, port);
var addressCache = _connectionCache.GetValueOrDefault(cacheKey);
var cachedAddresses = new List<IPAddress>();
var uncachedAddresses = new List<IPAddress>();
foreach (var ip in addresses)
{
if (addressCache is not null &&
addressCache.TryGetValue(ip, out var successTime) &&
now - successTime <= _CacheDuration)
{
cachedAddresses.Add(ip);
}
else
{
uncachedAddresses.Add(ip);
}
}
// 缓存地址: 按成功时间倒序 (最近成功的优先)
cachedAddresses.Sort((a, b) =>
addressCache![b].CompareTo(addressCache[a]));
// 非缓存地址: IPv6 优先 + 保持原始顺序
var ipv6List = uncachedAddresses
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetworkV6)
.OrderBy(ip => Array.IndexOf(addresses, ip))
.ToList();
var ipv4List = uncachedAddresses
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetwork)
.OrderBy(ip => Array.IndexOf(addresses, ip))
.ToList();
// 交错合并,确保有一个 v6 和一个 v4
var sortedUncached = new List<IPAddress>();
int i = 0, j = 0;
while (i < ipv6List.Count || j < ipv4List.Count)
{
if (i < ipv6List.Count)
sortedUncached.Add(ipv6List[i++]);
if (j < ipv4List.Count)
sortedUncached.Add(ipv4List[j++]);
}
return cachedAddresses
.Concat(sortedUncached)
.Take(WaitTasks)
.ToArray();
}
private void _UpdateConnectionCache(string host, int port, IPAddress ip, DateTime now)
{
var cacheKey = (host, port);
var addressCache = _connectionCache.GetOrAdd(cacheKey, static _ =>
new ConcurrentDictionary<IPAddress, DateTime>());
// 移除过期条目 (懒清理)
foreach (var entry in addressCache)
{
if (now - entry.Value > _CacheDuration)
{
addressCache.TryRemove(entry.Key, out _);
}
}
// 更新当前IP
addressCache[ip] = now;
}
private static async Task<NetworkStream> _DelayedConnectAsync(IPAddress ip, int port, int delay, CancellationToken cancellationToken)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
return await _ConnectToAddressAsync(ip, port, cancellationToken).ConfigureAwait(false);
}
private static async Task<NetworkStream> _ConnectToAddressAsync(IPAddress ip, int port, CancellationToken cancellationToken)
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true
};
try
{
await socket.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
socket.Dispose();
throw new HttpRequestException($"Connection to {ip}:{port} failed", ex);
}
}
private static async Task _CleanupUnusedConnectionsAsync(
List<Task<NetworkStream>> allTasks,
Task<NetworkStream>? winnerTask)
{
foreach (var task in allTasks.Where(t => t != winnerTask))
{
try
{
if (task.IsCompletedSuccessfully)
{
var stream = await task.ConfigureAwait(false);
await stream.DisposeAsync().ConfigureAwait(false);
}
else if (task.IsCompleted)
{
_ = task.Exception;
}
else
{
_ = task.ContinueWith(static t =>
{
if (t.IsCompletedSuccessfully)
{
t.Result.Dispose();
}
else
{
_ = t.Exception;
}
}, TaskScheduler.Default);
}
}
catch (Exception ex)
{
LogWrapper.Warn(ex, ModuleName, "Dispose connection with an error");
}
}
}
}
@@ -0,0 +1,16 @@
using System;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public static class HttpBasicExtension
{
extension(HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithHttpVersionOption(Version httpVersion)
{
requestMessage.Version = httpVersion;
return requestMessage;
}
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using PCL.Core.Utils;
namespace PCL.Core.IO.Net.Http;
public static class HttpContentExtension
{
extension (HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithContent(HttpContent content, string? contentType = null)
{
requestMessage.Content = content;
if (contentType is not null) requestMessage.WithHeader("Content-Type", contentType);
return requestMessage;
}
public HttpRequestMessage WithContent(string content, string? contentType = null)
{
requestMessage.Content = contentType is null
? new StringContent(content, Encoding.UTF8)
: new StringContent(content, Encoding.UTF8, contentType);
return requestMessage;
}
public HttpRequestMessage WithBinaryContent(byte[] content)
{
requestMessage.Content = new ByteArrayContent(content);
return requestMessage;
}
public HttpRequestMessage WithJsonContent<T>(T content, string? contentType = null, JsonSerializerOptions? options = null)
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(content, options ?? JsonCompat.SerializerOptions),
Encoding.UTF8,
contentType ?? "application/json");
return requestMessage;
}
public HttpRequestMessage WithFormContent(string form)
{
return requestMessage.WithContent(
new ByteArrayContent(Encoding.UTF8.GetBytes(form)),
"application/x-www-form-urlencoded");
}
public HttpRequestMessage WithFormContent(IEnumerable<KeyValuePair<string, string>> pairs)
{
requestMessage.Content = new FormUrlEncodedContent(pairs);
return requestMessage;
}
}
}
@@ -0,0 +1,43 @@
using System;
using System.Linq;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public static class HttpCookieExtension
{
extension (HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithCookie(string name, string value)
{
ArgumentNullException.ThrowIfNull(requestMessage);
ArgumentNullException.ThrowIfNullOrEmpty(value);
ArgumentNullException.ThrowIfNull(value);
var newCookie = $"{name}={_GetSafeCookieValue(value)}";
if (requestMessage.Headers.TryGetValues("Cookie", out var existingValues))
{
var existingCookie = string.Join("; ", existingValues);
requestMessage.Headers.Remove("Cookie");
requestMessage.Headers.Add("Cookie", $"{existingCookie}; {newCookie}");
}
else
{
requestMessage.Headers.Add("Cookie", newCookie);
}
return requestMessage;
}
}
private static string _GetSafeCookieValue(string value)
{
if (string.IsNullOrEmpty(value)) return value;
var needsEncoding = value.Any(c => _ForbiddenCookieValueChar.Contains(c) || char.IsControl(c));
return needsEncoding ? Uri.EscapeDataString(value) : value;
}
private static readonly char[] _ForbiddenCookieValueChar = [';', ',', ' ', '\r', '\n', '\t', '\0', '=', '"', '\'', '\\', '<', '>'];
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
namespace PCL.Core.IO.Net.Http;
public static class HttpHeaderHandler
{
extension (HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithHeader(string key, string value)
{
if (key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && requestMessage.Content is not null)
requestMessage.Content.Headers.TryAddWithoutValidation(key, value);
else
requestMessage.Headers.TryAddWithoutValidation(key, value);
return requestMessage;
}
public HttpRequestMessage WithHeaders(IDictionary<string, string> pairs)
{
ArgumentNullException.ThrowIfNull(pairs);
foreach (var item in pairs)
{
requestMessage.WithHeader(item.Key, item.Value);
}
return requestMessage;
}
public HttpRequestMessage WithHeader(KeyValuePair<string, string> pair) =>
requestMessage.WithHeader(pair.Key, pair.Value);
public HttpRequestMessage WithAuthentication(string scheme, string token)
{
ArgumentException.ThrowIfNullOrEmpty(scheme);
ArgumentException.ThrowIfNullOrEmpty(token);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
return requestMessage;
}
public HttpRequestMessage WithBearerToken(string token) =>
requestMessage.WithAuthentication("Bearer", token);
}
}
@@ -0,0 +1,224 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Microsoft.Win32;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using PCL.Core.Utils.OS;
namespace PCL.Core.IO.Net.Http;
public class HttpProxyManager : IWebProxy, IDisposable
{
public static readonly HttpProxyManager Instance = new();
public enum ProxyMode
{
NoProxy,
SystemProxy,
CustomProxy
}
private readonly object _lock = new();
private ProxyMode _mode = ProxyMode.SystemProxy;
private readonly WebProxy _customWebProxy = new() { BypassProxyOnLocal = true };
private readonly WebProxy _systemWebProxy = new() { BypassProxyOnLocal = true };
private const string ProxyRegPathFull = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
private const string ProxyRegPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
private readonly RegistryChangeMonitor _proxyMonitor = new(ProxyRegPath);
private HttpProxyManager()
{
RefreshSystemProxy(); // 初始化系统代理
_proxyMonitor.Changed += _OnSystemProxyChanged;
}
private void _OnSystemProxyChanged(object? sender, EventArgs e)
{
RefreshSystemProxy();
}
private enum ProxyProtocol
{
Http,
Socks
}
private record ProxyItem
{
public ProxyProtocol Protocol;
public required string Address;
}
private static ProxyItem[] _GetProxyFromString(string? proxyString)
{
if (proxyString.IsNullOrWhiteSpace()) return [];
var ret = new List<ProxyItem>();
// 形式:http=192.168.1.100:8080;socks=192.168.1.100:1080
if (proxyString.Contains('='))
{
foreach (var segment in proxyString.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var eqIndex = segment.IndexOf('=');
if (eqIndex <= 0 || eqIndex >= segment.Length - 1)
continue;
var protocolStr = segment[..eqIndex].Trim();
var address = segment[(eqIndex + 1)..].Trim();
if (string.IsNullOrWhiteSpace(address))
continue;
ret.Add(new ProxyItem { Protocol = _ParseProtocol(protocolStr), Address = address });
}
return ret.Count > 0 ? [.. ret] : [];
}
// 形式:http://127.0.0.1:1145/ 或者单纯 127.0.0.1:1145
if (Uri.TryCreate(proxyString, new UriCreationOptions(), out var proxyAddr))
{
var address = proxyAddr.Port > 0
? $"{proxyAddr.Host}:{proxyAddr.Port}"
: proxyAddr.Host;
ret.Add(new ProxyItem { Protocol = _ParseProtocol(proxyAddr.Scheme), Address = address });
}
else
{
ret.Add(new ProxyItem { Protocol = ProxyProtocol.Http, Address = proxyString.Trim() });
}
return [.. ret];
}
private static ProxyProtocol _ParseProtocol(string scheme)
{
return scheme.ToLowerInvariant() switch
{
"socks" or "socks4" or "socks5" => ProxyProtocol.Socks,
_ => ProxyProtocol.Http
};
}
/// <summary>刷新系统代理设置</summary>
public void RefreshSystemProxy()
{
lock (_lock)
{
try
{
// read from reg
var isSystemProxyEnabled = (int)(Registry.GetValue(ProxyRegPathFull, "ProxyEnable", 0) ?? 0);
var systemProxyString = Registry.GetValue(ProxyRegPathFull, "ProxyServer", string.Empty) as string;
// parse
var proxies = _GetProxyFromString(systemProxyString);
// filter
if (proxies.Length == 0 || !proxies.Any(static x => x.Protocol.Equals(ProxyProtocol.Http))) isSystemProxyEnabled = 0;
var selectedProxy = proxies.FirstOrDefault(static x => x.Protocol.Equals(ProxyProtocol.Http));
// apply
_systemWebProxy.Address = (isSystemProxyEnabled == 0 || selectedProxy!.Address.IsNullOrEmpty())
? null
: new Uri($"http://{selectedProxy.Address}");
LogWrapper.Info("Proxy",
$"已从操作系统更新代理设置,系统代理状态:{isSystemProxyEnabled}|{systemProxyString}");
}
catch (Exception ex)
{
LogWrapper.Error(ex, "Proxy", "获取系统代理时出现异常");
}
}
}
public ProxyMode Mode
{
get { lock (_lock) return _mode; }
set { lock (_lock) _mode = value; }
}
public Uri? CustomProxyAddress
{
get { lock (_lock) return _customWebProxy.Address; }
set { lock (_lock) _customWebProxy.Address = value; }
}
public ICredentials? CustomProxyCredentials
{
get { lock (_lock) return _customWebProxy.Credentials; }
set { lock (_lock) _customWebProxy.Credentials = value; }
}
public bool BypassOnLocal
{
get { lock (_lock) return field; }
set
{
lock (_lock)
{
field = value;
_systemWebProxy.BypassProxyOnLocal = value;
}
}
} = true;
public Uri? GetProxy(Uri destination)
{
lock (_lock)
{
return _mode switch
{
ProxyMode.NoProxy => null, // 返回 null 表明没有代理
ProxyMode.SystemProxy => _systemWebProxy.GetProxy(destination),
ProxyMode.CustomProxy => _customWebProxy.GetProxy(destination),
_ => null
};
}
}
public bool IsBypassed(Uri host)
{
lock (_lock)
{
return _mode switch
{
ProxyMode.NoProxy => true,
ProxyMode.SystemProxy => _systemWebProxy.IsBypassed(host),
ProxyMode.CustomProxy => _customWebProxy.IsBypassed(host),
_ => true
};
}
}
public ICredentials? Credentials
{
get
{
lock (_lock)
{
// 仅 CustomProxy 模式返回凭据
return _mode == ProxyMode.CustomProxy
? _customWebProxy.Credentials
: null;
}
}
set
{
lock (_lock)
{
_customWebProxy.Credentials = value;
}
}
}
public void Dispose()
{
_proxyMonitor.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,49 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http;
public static class HttpRequest
{
public static HttpRequestMessage Create(string url)
{
return new HttpRequestMessage(HttpMethod.Get, new Uri(url));
}
public static HttpRequestMessage CreateHead(string url)
{
return new HttpRequestMessage(HttpMethod.Head, new Uri(url));
}
public static HttpRequestMessage CreatePost(string url)
{
return new HttpRequestMessage(HttpMethod.Post, new Uri(url));
}
public static HttpRequestMessage CreatePut(string url)
{
return new HttpRequestMessage(HttpMethod.Put, new Uri(url));
}
public static HttpRequestMessage CreateDelete(string url)
{
return new HttpRequestMessage(HttpMethod.Delete, new Uri(url));
}
public static async Task<string> GetStringAsync(string url)
{
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
return await resp.AsStringAsync().ConfigureAwait(false);
}
public static async Task<T?> GetJsonAsync<T>(string url)
{
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
return await resp.AsJsonAsync<T>().ConfigureAwait(false);
}
public static async Task<HttpResponseMessage> PostJsonAsync<T>(string url, T data, string? contentType = null)
{
return await CreatePost(url)
.WithJsonContent(data, contentType)
.SendAsync()
.ConfigureAwait(false);
}
}
@@ -0,0 +1,48 @@
using System;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public class HttpResponseException:HttpRequestException,IDisposable
{
public HttpResponseMessage? Response { get; init; }
public HttpResponseException()
{
}
public HttpResponseException(string message) : base(message)
{
}
public HttpResponseException(string message, Exception? inner) : base(message, inner)
{
}
public HttpResponseException(HttpResponseMessage? response) : this(
$"{(int?)response?.StatusCode} {response?.ReasonPhrase ?? (response is null ? "undefined" : Enum.GetName(response.StatusCode))})")
{
Response = response;
}
public void Dispose()
{
Response?.Dispose();
GC.SuppressFinalize(this);
}
~HttpResponseException()
{
try
{
Response?.Dispose();
}
catch
{
// Suppress Exception
}
}
}
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Utils;
namespace PCL.Core.IO.Net.Http;
public static class HttpResponseExtension
{
extension(HttpResponseMessage responseMessage)
{
public bool IsSuccess => responseMessage.IsSuccessStatusCode;
public string AsString() { return responseMessage.AsStringAsync().GetAwaiter().GetResult(); }
public async Task<string> AsStringAsync(CancellationToken ct = default)
{
try
{
return await responseMessage.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
} catch(TaskCanceledException)
{
throw new TimeoutException("The request was canceled due to a timeout.");
} catch(OperationCanceledException)
{
throw new TimeoutException("The operation was canceled.");
}
}
public async Task<Stream> AsStreamAsync(CancellationToken ct = default)
{
try
{
return await responseMessage.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
} catch(TaskCanceledException)
{
throw new TimeoutException("The request was canceled due to a timeout.");
} catch(OperationCanceledException)
{
throw new TimeoutException("The operation was canceled.");
}
}
public async Task<byte[]> AsByteArrayAsync(CancellationToken ct = default)
{
try
{
return await responseMessage.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
} catch(TaskCanceledException)
{
throw new TimeoutException("The request was canceled due to a timeout.");
} catch(OperationCanceledException)
{
throw new TimeoutException("The operation was canceled.");
}
}
public async Task<T?> AsJsonAsync<T>(
JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default)
{
try
{
await using var stream = await responseMessage.AsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<T>(stream, options ?? JsonCompat.SerializerOptions, cancellationToken)
.ConfigureAwait(false);
} catch(JsonException ex)
{
throw new InvalidDataException("Failed to deserialize JSON response.", ex);
}
}
public Dictionary<string, string[]> GetHeaders()
{
return responseMessage.Headers
.Concat(responseMessage.Content.Headers)
.ToDictionary(k => k.Key, v => v.Value.ToArray());
}
public Dictionary<string, string[]> GetContentHeaders()
{ return responseMessage.Content.Headers.ToDictionary(k => k.Key, v => v.Value.ToArray()); }
public string[] GetHeader(string name)
{
if(responseMessage.Headers.TryGetValues(name, out var values) ||
responseMessage.Content.Headers.TryGetValues(name, out values))
return values.ToArray();
return [];
}
public bool TryGetHeader(string name, out string[] values)
{
if(responseMessage.Headers.TryGetValues(name, out var headerValues) ||
responseMessage.Content.Headers.TryGetValues(name, out headerValues))
{
values = headerValues.ToArray();
return true;
}
values = [];
return false;
}
public string? GetFirstHeaderValue(string name) { return responseMessage.GetHeader(name).FirstOrDefault(); }
public bool TryGetFirstHeaderValue(string name, out string value)
{
var values = responseMessage.GetHeader(name);
if(values.Length == 0)
{
value = string.Empty;
return false;
}
value = values.First();
return true;
}
public void EnsureSuccessStatusCode()
{
if(!responseMessage.IsSuccess)
throw new HttpRequestException(
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}");
}
public async Task EnsureSuccessStatusCodeWithContentAsync(CancellationToken ct = default)
{
if(!responseMessage.IsSuccess)
{
var content = await responseMessage
.AsStringAsync(ct)
.ConfigureAwait(false);
throw new HttpRequestException(
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}. Response content: {content}");
}
}
}
}
@@ -0,0 +1,10 @@
using System;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public class HttpRoute(HttpMethod method, string path) : Attribute
{
public HttpMethod Method { get; } = method;
public string Path { get; } = path;
}
@@ -0,0 +1,154 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PCL.Core.Utils;
namespace PCL.Core.IO.Net.Http;
/// <summary>
/// 用于 <see cref="HttpServer"/> 响应客户端请求的服务端响应结构。
/// </summary>
[Serializable]
public class HttpRouteResponse
{
/// <summary>
/// HTTP 状态码
/// </summary>
public HttpStatusCode? StatusCode = null;
/// <summary>
/// 此响应 <see cref="InputStream"/> 使用的字符编码
/// </summary>
public Encoding? ContentEncoding = null;
/// <summary>
/// [Header] 内容 MIME 类型
/// </summary>
public string? ContentType = null;
/// <summary>
/// [Header] 重定向目标 URL 或路径。
/// </summary>
public string? RedirectLocation = null;
/// <summary>
/// [Header] 是否使用分块传输编码。
/// </summary>
public bool? SendChunked = null;
/// <summary>
/// 随响应添加的 Cookies。
/// </summary>
public CookieCollection? Cookies = null;
/// <summary>
/// 用于传输响应内容的输入流,若非空值,该流将被直接 <c>CopyTo</c> 到实际响应的 <c>OutputStream</c> 中。
/// </summary>
public Stream? InputStream = null;
/// <summary>
/// 向标准 <see cref="HttpListener"/> 的响应对象写入数据。
/// </summary>
/// <param name="target">目标对象</param>
public void Pour(HttpListenerResponse target)
{
target.StatusCode = (int)(StatusCode ?? HttpStatusCode.OK);
if (ContentType is {} contentType) target.ContentType = contentType;
if (ContentEncoding is {} contentEncoding) target.ContentEncoding = contentEncoding;
if (RedirectLocation is {} redirectLocation) target.RedirectLocation = redirectLocation;
if (SendChunked is {} sendChunked) target.SendChunked = sendChunked;
if (Cookies is {} cookies) target.Cookies = cookies;
if (InputStream is {} inputStream) inputStream.CopyTo(target.OutputStream);
}
public Task<HttpRouteResponse> AsTask() => Task.FromResult(this);
/// <summary>
/// 返回指定 HTTP 状态码的空响应
/// </summary>
/// <param name="statusCode">HTTP 状态码</param>
public static HttpRouteResponse Empty(HttpStatusCode statusCode) => new() { StatusCode = statusCode };
/// <summary>
/// 默认的 204 (No Content) 响应。
/// </summary>
public static readonly HttpRouteResponse NoContent = Empty(HttpStatusCode.NoContent);
/// <summary>
/// 默认的 400 (Bad Request) 响应。
/// </summary>
public static readonly HttpRouteResponse BadRequest = Empty(HttpStatusCode.BadRequest);
/// <summary>
/// 默认的 403 (Forbidden) 响应。
/// </summary>
public static readonly HttpRouteResponse Forbidden = Empty(HttpStatusCode.Forbidden);
/// <summary>
/// 默认的 404 (Not Found) 响应。
/// </summary>
public static readonly HttpRouteResponse NotFound = Empty(HttpStatusCode.NotFound);
/// <summary>
/// 默认的 500 (Internal Server Error) 响应。
/// </summary>
public static readonly HttpRouteResponse InternalServerError = Empty(HttpStatusCode.InternalServerError);
/// <summary>
/// 默认的 502 (Bad Gateway) 响应。
/// </summary>
public static readonly HttpRouteResponse BadGateway = Empty(HttpStatusCode.BadGateway);
/// <summary>
/// 响应指定输入流的内容。
/// </summary>
/// <param name="stream">输入流</param>
/// <param name="contentType">内容 MIME 类型</param>
/// <param name="encoding">输入流内容使用的字符编码,默认为 UTF-8</param>
public static HttpRouteResponse Input(Stream stream, string contentType = "application/octet-stream", Encoding? encoding = null) =>
new() { InputStream = stream, ContentType = contentType, ContentEncoding = encoding ?? Encoding.UTF8 };
/// <summary>
/// 响应指定文本内容。
/// </summary>
/// <param name="text">文本内容</param>
/// <param name="contentType">内容 MIME 类型</param>
/// <param name="encoding">响应流使用的字符编码,默认为 UTF-8</param>
public static HttpRouteResponse Text(string text, string contentType = "text/plain", Encoding? encoding = null) =>
Input(new StringStream(text, encoding), contentType, encoding);
/// <summary>
/// 响应重定向。
/// </summary>
/// <param name="location">重定向目标 URL 或路径</param>
/// <param name="statusCode">重定向状态码</param>
public static HttpRouteResponse Redirect(string location, HttpStatusCode statusCode = HttpStatusCode.Found) =>
new() { StatusCode = statusCode, RedirectLocation = location };
/// <summary>
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
/// </summary>
/// <param name="obj">用于序列化的对象</param>
/// <param name="options">JSON 序列化选项</param>
public static HttpRouteResponse Json(object obj, JsonSerializerOptions? options)
{
var stream = new MemoryStream();
JsonSerializer.Serialize(stream, obj, options ?? JsonCompat.SerializerOptions);
stream.Position = 0;
return new HttpRouteResponse
{
ContentEncoding = Encoding.UTF8,
ContentType = "application/json, charset=utf-8",
InputStream = stream
};
}
/// <summary>
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
/// </summary>
/// <param name="obj">用于序列化的对象</param>
public static HttpRouteResponse Json(object obj) => Json(obj, JsonCompat.SerializerOptions);
}
@@ -0,0 +1,67 @@
using PCL.Core.App;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http;
public static class HttpSenderExtension
{
extension(HttpRequestMessage requestMessage)
{
public async Task<HttpResponseMessage> SendAsync(
HttpClient? httpClient = null,
bool addMetedata = true,
bool enableLogging = true,
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
int retryTimes = 3,
CancellationToken cancellationToken = default)
{
using var request = requestMessage;
httpClient ??= NetworkService.GetClient();
if(addMetedata)
{
request
.WithHeader("User-Agent", $"PCL-Community/PCL2-CE/{Basics.VersionName} (pclc.cc)")
.WithHeader("Referer", $"https://{Basics.VersionCode}.ce.open.pcl2.server/");
}
var requestId = Guid.NewGuid().ToString();
if (enableLogging)
LogWrapper.Info(
"Request",
$"Send request to {request.RequestUri} (method = {request.Method}, id = {requestId})");
var resp = await NetworkService.GetRetryPolicy(retryTimes)
.ExecuteAsync(
async token =>
{
if (enableLogging)
LogWrapper.Debug("Request", $"Try attempt (id = {requestId})");
try
{
using var requestCopy = await request
.CloneAsync()
.ConfigureAwait(false);
return await httpClient
.SendAsync(requestCopy, httpCompletionOption, token)
.ConfigureAwait(false);
}catch(Exception ex)
{
LogWrapper.Debug(ex, "Request", $"Try attempt failed (id = {requestId})");
throw;
}
},cancellationToken
)
.ConfigureAwait(false);
if (enableLogging)
LogWrapper.Info("Request", $"End request, got http status code {resp.StatusCode} (id = {requestId})");
return resp;
}
}
}
@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http;
public abstract class HttpServer : IDisposable
{
private readonly HttpListener _server = new();
public readonly ushort Port;
public readonly string[] Host;
private Task? _handleLoop;
private CancellationTokenSource? _cancellationTokenSource;
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
private bool _initialized = false;
protected HttpServer(IPAddress[] listenAddr, ushort port = 0)
{
// Check parameters
ArgumentNullException.ThrowIfNull(listenAddr);
// Resolve port
if (port == 0) port = (ushort)NetworkHelper.NewTcpPort();
Port = port;
// Resolve host
if (listenAddr.Length == 0)
listenAddr = [IPAddress.Loopback, IPAddress.IPv6Loopback];
var hosts = new List<string>();
foreach (var address in listenAddr)
{
_server.Prefixes.Add($"http://{address}:{port}/");
hosts.Add(address.ToString());
}
Host = hosts.ToArray();
}
/// <summary>
/// 初始化路由。子类应在此方法中调用 Register 方法注册路由。
/// </summary>
protected abstract void Init();
/// <summary>
/// 注册一个路由处理器。
/// </summary>
/// <param name="method">HTTP 方法</param>
/// <param name="path">路由路径</param>
/// <param name="handler">请求处理函数</param>
protected void Register(HttpMethod method, string path, Func<HttpListenerRequest, Task<HttpRouteResponse>> handler)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(path);
ArgumentNullException.ThrowIfNull(handler);
_handlers[(method, path)] = handler;
}
/// <summary>
/// 启动 HTTP 服务器。
/// </summary>
public void Start()
{
// 如果没有注册路由,调用 Init 初始化
if (!_initialized && _handlers.Count == 0)
{
Init();
_initialized = true;
}
_cancellationTokenSource = new CancellationTokenSource();
_server.Start();
_handleLoop = _HandleRequestAsync();
}
private async Task _HandleRequestAsync()
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var context = await _server.GetContextAsync();
_ = Task.Run(async () => await _ProcessRequestAsync(context), cancellationToken);
}
catch (OperationCanceledException) { break; } // Cancellation
catch (ObjectDisposedException) { break; } // Disposed
catch (HttpListenerException) { break; } // Closed
}
}
private async Task _ProcessRequestAsync(HttpListenerContext context)
{
try
{
var request = context.Request;
var response = context.Response;
var path = request.Url?.AbsolutePath ?? string.Empty;
var method = new HttpMethod(request.HttpMethod);
// 首先尝试精确匹配
if (_handlers.TryGetValue((method, path), out var handler))
{
await _ExecuteHandlerAsync(handler, request, response);
return;
}
// 如果没有精确匹配,尝试通配符匹配
if (_handlers.TryGetValue((method, "*"), out var wildcardHandler))
{
await _ExecuteHandlerAsync(wildcardHandler, request, response);
return;
}
// 没有找到匹配的路由
response.StatusCode = (int)HttpStatusCode.NotFound;
}
finally
{
try
{
context.Response.Close();
}
catch
{
// Ignore errors when closing response
}
}
}
private static async Task _ExecuteHandlerAsync(Func<HttpListenerRequest, Task<HttpRouteResponse>> handler, HttpListenerRequest request, HttpListenerResponse response)
{
try
{
var routeResponse = await handler(request);
routeResponse.Pour(response);
}
catch (Exception ex)
{
response.StatusCode = (int)HttpStatusCode.InternalServerError;
response.ContentEncoding = System.Text.Encoding.UTF8;
response.ContentType = "text/plain";
var errorResponse =
HttpRouteResponse.Text($"Internal Server Error:\n{ex}", "text/plain", System.Text.Encoding.UTF8);
errorResponse.Pour(response);
}
}
/// <summary>
/// 停止 HTTP 服务器。
/// </summary>
public void Stop()
{
_cancellationTokenSource?.Cancel();
_server.Stop();
}
public void Dispose()
{
GC.SuppressFinalize(this);
Stop();
_server.Close();
_cancellationTokenSource?.Dispose();
}
}