204 lines
8.6 KiB
C#
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。");
|
|
}
|
|
|