147 lines
6.4 KiB
C#
147 lines
6.4 KiB
C#
using CsharpHttpHelper;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Work.Store.Api;
|
|
|
|
/// <summary>管理 API 的统一 HTTP 出口,负责 JSON、Bearer 认证和错误处理。</summary>
|
|
public sealed class ApiTransport
|
|
{
|
|
private readonly string _baseUrl;
|
|
private string _token;
|
|
private Func<string> _login;
|
|
internal object SessionGate { get; } = new object();
|
|
|
|
internal void SetSession(string token, Func<string> login)
|
|
{
|
|
lock (SessionGate)
|
|
{
|
|
_token = token;
|
|
_login = login;
|
|
}
|
|
}
|
|
|
|
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 { lock (SessionGate) return _token; }
|
|
set
|
|
{
|
|
if (value != null && (string.IsNullOrWhiteSpace(value) || value.IndexOfAny(new[] { '\r', '\n' }) >= 0))
|
|
throw new ArgumentException("Token 不能为空白或包含换行。", nameof(value));
|
|
SetSession(value, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>同步请求;无 data 时返回 default(T),界面调用需放在后台线程。</summary>
|
|
public T Send<T>(string route, MethodType method, object body = null,
|
|
IDictionary<string, string> query = null, bool authenticated = true,
|
|
int timeoutMilliseconds = 30000)
|
|
{
|
|
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));
|
|
string token = authenticated ? Token : null;
|
|
if (authenticated && token == null)
|
|
throw new HttpException("请先登录。") { IsOnline = false };
|
|
|
|
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;
|
|
}
|
|
}
|
|
var item = 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
|
|
};
|
|
for (int attempt = 0; ; attempt++)
|
|
{
|
|
if (token != null) item.Header["Authorization"] = "Bearer " + token;
|
|
var response = HttpHelper.Request(item);
|
|
int status = (int)response.StatusCode;
|
|
if (status == 0) throw new HttpException("无法连接服务器或请求超时。");
|
|
|
|
JObject result = null;
|
|
JsonException parseError = null;
|
|
try { result = JObject.Parse(response.Html ?? ""); }
|
|
catch (JsonException ex) { parseError = ex; }
|
|
|
|
var code = result?["code"];
|
|
if (status == 401 || (code?.Type == JTokenType.Integer && code.ToString() == "401"))
|
|
{
|
|
if (authenticated)
|
|
{
|
|
lock (SessionGate)
|
|
{
|
|
// 并发旧请求复用已更新的 Token;同一请求最多重新发送一次。
|
|
if (_token == token)
|
|
{
|
|
_token = null;
|
|
if (attempt == 0 && _login != null)
|
|
_token = _login();
|
|
}
|
|
if (attempt == 0 && _token != null)
|
|
{
|
|
token = _token;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
throw new HttpException(result?["msg"]?.ToString() ?? "登录已失效,请重新登录。") { IsOnline = false };
|
|
}
|
|
if (status < 200 || status >= 300)
|
|
throw new HttpException($"请求失败(HTTP {status}):{result?["msg"]}");
|
|
if (result == null)
|
|
throw new HttpException("服务器响应不是有效的 JSON 对象。", parseError);
|
|
// 先检查业务码,避免错误响应的 data 结构掩盖服务端错误。
|
|
if (code?.Type != JTokenType.Integer || code.ToString() != "200")
|
|
throw new ApiException("请求失败:" + (result["msg"]?.ToString() ?? "响应缺少成功状态码。"),
|
|
code?.Type == JTokenType.Integer && int.TryParse(code.ToString(), out var businessCode) ? businessCode : (int?)null,
|
|
result["data"]);
|
|
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。");
|
|
}
|
|
|