Files
KJD/Work/Store.Api/ApiTransport.cs
T
2026-11-05 18:50:13 +08:00

204 lines
8.6 KiB
C#

using CsharpHttpHelper;
using AllBase.Model.Result;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Work.Store.Api;
/// <summary>管理 API 的统一 HTTP 出口,负责 JSON、Bearer 认证和错误处理。</summary>
public sealed class ApiTransport
{
private readonly string _baseUrl;
private string _token;
private LoginRequest _credentials;
internal SemaphoreSlim SessionGate { get; } = new SemaphoreSlim(1, 1);
// 调用方持有 SessionGate。
internal void SetSession(string token, LoginRequest credentials)
{
_token = token;
_credentials = credentials;
}
public ApiTransport(string baseUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https") ||
uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0)
throw new ArgumentException("必须提供无凭据、查询参数和片段的 HTTP(S) 根地址。", nameof(baseUrl));
_baseUrl = uri.AbsoluteUri.TrimEnd('/') + "/";
}
/// <summary>仅存于内存;手动赋值会清除自动登录凭据,设为 null 退出本地会话。</summary>
public string Token
{
get
{
SessionGate.Wait();
try { return _token; }
finally { SessionGate.Release(); }
}
set
{
if (value != null && (string.IsNullOrWhiteSpace(value) || value.IndexOfAny(new[] { '\r', '\n' }) >= 0))
throw new ArgumentException("Token 不能为空白或包含换行。", nameof(value));
SessionGate.Wait();
try { SetSession(value, null); }
finally { SessionGate.Release(); }
}
}
public T Send<T>(string route, MethodType method, object body = null,
IDictionary<string, string> query = null, bool authenticated = true,
int timeoutMilliseconds = 30000)
{
var item = CreateRequest(route, method, body, query, timeoutMilliseconds);
string token = null;
if (authenticated)
{
//SessionGate.Wait();
//try { token = _token ?? throw new HttpException("请先登录。") { IsOnline = false }; }
//finally { SessionGate.Release(); }
}
for (int attempt = 0; ; attempt++)
{
if (token != null) item.Header["Authorization"] = "Bearer " + token;
var response = HttpHelper.Request(item);
try { return ReadResult<T>(response); }
catch (HttpException ex) when (authenticated && !ex.IsOnline)
{
SessionGate.Wait();
try
{
if (_token == token)
{
_token = null;
if (attempt == 0 && _credentials != null)
_token = AuthApi.LoginCore(this, _credentials).Token;
}
if (attempt != 0 || _token == null) throw;
token = _token;
}
finally { SessionGate.Release(); }
}
}
}
public async Task<T> SendAsync<T>(string route, MethodType method, object body = null,
IDictionary<string, string> query = null, bool authenticated = true,
int timeoutMilliseconds = 30000)
{
var item = CreateRequest(route, method, body, query, timeoutMilliseconds);
string token = null;
if (authenticated)
{
//await SessionGate.WaitAsync().ConfigureAwait(false);
//try { token = _token ?? throw new HttpException("请先登录。") { IsOnline = false }; }
//finally { SessionGate.Release(); }
}
for (int attempt = 0; ; attempt++)
{
if (token != null) item.Header["Authorization"] = "Bearer " + token;
var response = await HttpHelper.RequestAsync(item).ConfigureAwait(false);
try { return ReadResult<T>(response); }
catch (HttpException ex) when (authenticated && !ex.IsOnline)
{
await SessionGate.WaitAsync().ConfigureAwait(false);
try
{
if (_token == token)
{
_token = null;
if (attempt == 0 && _credentials != null)
_token = (await AuthApi.LoginCoreAsync(this, _credentials).ConfigureAwait(false)).Token;
}
if (attempt != 0 || _token == null) throw;
token = _token;
}
finally { SessionGate.Release(); }
}
}
}
private HttpItem CreateRequest(string route, MethodType method, object body,
IDictionary<string, string> query, int timeoutMilliseconds)
{
if (string.IsNullOrWhiteSpace(route) ||
!Regex.IsMatch(route, @"\A[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*\z"))
throw new ArgumentException("接口必须使用相对路由,例如 Auth/Login。", nameof(route));
if (body != null) Validator.ValidateObject(body, new ValidationContext(body), true);
if (timeoutMilliseconds <= 0) throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds));
var url = new StringBuilder(_baseUrl).Append(route);
if (query != null)
{
bool first = true;
foreach (var pair in query)
{
if (pair.Value == null) continue;
url.Append(first ? '?' : '&').Append(Uri.EscapeDataString(pair.Key))
.Append('=').Append(Uri.EscapeDataString(pair.Value));
first = false;
}
}
return new HttpItem
{
URL = url.ToString(),
Method = method,
ContentType = "application/json; charset=utf-8",
Accept = "application/json",
PostDataType = PostDataType.Byte,
PostdataByte = body == null ? null : Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body)),
Encoding = Encoding.UTF8,
Timeout = timeoutMilliseconds,
ReadWriteTimeout = timeoutMilliseconds,
Allowautoredirect = false
};
}
private static T ReadResult<T>(HttpResult response)
{
int status = (int)response.StatusCode;
if (status == 0) throw new HttpException("无法连接服务器或请求超时。");
Result<JToken> result = null;
JsonException parseError = null;
try { result = JsonConvert.DeserializeObject<Result<JToken>>(response.Html ?? ""); }
catch (JsonException ex) { parseError = ex; }
if (status == 401 || (result?.Success == false && result.Code == 401))
throw new HttpException(result?.Msg ?? "登录已失效,请重新登录。") { IsOnline = false };
// 统一错误响应即使使用 HTTP 4xx/5xx,也保留业务码、数据和追踪 ID。
if (result?.Success == false)
throw new ApiException("请求失败:" + (result.Msg ?? $"业务码 {result.Code}。"),
result.Code, result.Data, result.TraceId);
if (status < 200 || status >= 300)
throw new HttpException($"请求失败(HTTP {status}):{result?.Msg}");
if (result?.Success == null)
throw new HttpException("服务器响应不是有效的 Result 格式。", parseError);
if (result.Data == null || result.Data.Type == JTokenType.Null) return default;
try { return result.Data.ToObject<T>(); }
catch (JsonException ex) { throw new HttpException("服务器响应 data 格式无效。", ex); }
}
public T GetRequired<T>(string route, MethodType method, object body = null,
IDictionary<string, string> query = null, bool authenticated = true,
int timeoutMilliseconds = 30000) where T : class
=> Send<T>(route, method, body, query, authenticated, timeoutMilliseconds)
?? throw new HttpException("服务器响应缺少 data。");
public async Task<T> GetRequiredAsync<T>(string route, MethodType method, object body = null,
IDictionary<string, string> query = null, bool authenticated = true,
int timeoutMilliseconds = 30000) where T : class
=> await SendAsync<T>(route, method, body, query, authenticated, timeoutMilliseconds).ConfigureAwait(false)
?? throw new HttpException("服务器响应缺少 data。");
}