Files
KJD/Work/Store.Api/ApiTransport.cs
T
2026-11-04 18:39:51 +08:00

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。");
}