133 lines
5.5 KiB
C#
133 lines
5.5 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Net;
|
|
using System.Text.Json;
|
|
using RestSharp;
|
|
using Store.Management.Api.Authentication;
|
|
using Store.Management.Api.Models;
|
|
|
|
namespace Store.Management.Api.Transport;
|
|
|
|
/// <summary>唯一 HTTP 出口;模块仅指定相对路由、请求数据与响应类型。</summary>
|
|
public sealed class ApiTransport : IDisposable
|
|
{
|
|
private readonly RestClient _client;
|
|
private readonly TokenSession _session;
|
|
private readonly string _version;
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
|
|
internal ApiTransport(ApiClientOptions options, TokenSession session, HttpClient? httpClient)
|
|
{
|
|
options.Validate();
|
|
_session = session;
|
|
_version = options.Version;
|
|
RestClientOptions settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"))
|
|
{
|
|
Timeout = options.Timeout,
|
|
FollowRedirects = false
|
|
};
|
|
_client = httpClient is null ? new RestClient(settings) : new RestClient(httpClient, settings, disposeHttpClient: false);
|
|
}
|
|
|
|
public async Task<T?> SendAsync<T>(string route, Method method, object? body = null,
|
|
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrWhiteSpace(route) || route.StartsWith('/') || route.Contains("..") ||
|
|
route.Contains(':') || route.Contains('?') || route.Contains('#') || route.Contains('\\'))
|
|
{
|
|
throw new ArgumentException("接口必须使用相对路由,例如 user/GetAllUser。", nameof(route));
|
|
}
|
|
if (body is not null)
|
|
{
|
|
Validator.ValidateObject(body, new ValidationContext(body), true);
|
|
}
|
|
string? token = authenticated ? _session.Token : null;
|
|
if (authenticated && token is null)
|
|
{
|
|
throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401);
|
|
}
|
|
RestRequest request = new RestRequest($"{_version}/{route}", method);
|
|
if (token is not null)
|
|
{
|
|
request.AddHeader("Authorization", $"Bearer {token}");
|
|
}
|
|
if (body is not null)
|
|
{
|
|
request.AddJsonBody(body);
|
|
}
|
|
if (query is not null)
|
|
{
|
|
foreach (KeyValuePair<string, string?> pair in query)
|
|
{
|
|
if (pair.Value is not null)
|
|
{
|
|
request.AddQueryParameter(pair.Key, pair.Value);
|
|
}
|
|
}
|
|
}
|
|
RestResponse response = await _client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (response.ResponseStatus == ResponseStatus.TimedOut)
|
|
{
|
|
throw new ApiException(ApiErrorKind.Timeout, "请求超时,请稍后重试。", innerException: response.ErrorException);
|
|
}
|
|
if ((int)response.StatusCode == 0)
|
|
{
|
|
throw new ApiException(ApiErrorKind.Network, "无法连接服务器,请检查网络与服务地址。", innerException: response.ErrorException);
|
|
}
|
|
|
|
// 先判断状态,再解析 data;错误响应的数据结构可能与成功响应不同。
|
|
ApiResult<JsonElement>? result = null;
|
|
JsonException? parseError = null;
|
|
try
|
|
{
|
|
result = JsonSerializer.Deserialize<ApiResult<JsonElement>>(response.Content ?? "", Json);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
parseError = ex;
|
|
}
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401)
|
|
{
|
|
_session.ClearIfCurrent(token);
|
|
throw new ApiException(ApiErrorKind.Unauthorized, result?.Msg ?? "登录已失效,请重新登录。",
|
|
response.StatusCode, 401, result?.TraceId);
|
|
}
|
|
if (!response.IsSuccessful)
|
|
{
|
|
throw new ApiException(ApiErrorKind.Http, result?.Msg ?? $"服务器请求失败({(int)response.StatusCode})。",
|
|
response.StatusCode, result?.Code, result?.TraceId);
|
|
}
|
|
if (result is null)
|
|
{
|
|
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应格式无效。", response.StatusCode, innerException: parseError);
|
|
}
|
|
if (result.Code != 200)
|
|
{
|
|
throw new ApiException(ApiErrorKind.Business, result.Msg ?? "操作失败。", response.StatusCode, result.Code, result.TraceId);
|
|
}
|
|
if (result.Data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
|
{
|
|
return default;
|
|
}
|
|
try
|
|
{
|
|
return result.Data.Deserialize<T>(Json);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应 data 格式无效。",
|
|
response.StatusCode, result.Code, result.TraceId, ex);
|
|
}
|
|
}
|
|
|
|
public async Task<T> GetRequiredAsync<T>(string route, Method method, object? body = null,
|
|
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true, CancellationToken cancellationToken = default)
|
|
where T : class
|
|
=> await SendAsync<T>(route, method, body, query, authenticated, cancellationToken).ConfigureAwait(false)
|
|
?? throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应缺少 data。");
|
|
|
|
public void Dispose() => _client.Dispose();
|
|
}
|