- Session: сохранение/восстановление сессии между запусками (JWT exp-проверка) - LoadPrice: страница 20000 вместо 500 — прайс грузится одним запросом - ApiClient: повтор запроса на транзиентных сбоях TLS (поиск/загрузка) - download-страница: реальный размер, macOS помечен «скоро» Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
11 KiB
C#
219 lines
11 KiB
C#
using System.Net;
|
||
using System.Net.Http;
|
||
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace Elfisa.Core;
|
||
|
||
/// <summary>
|
||
/// Клиент платформы. Портирован из WinForms-версии, оставлены методы,
|
||
/// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт.
|
||
/// Остальные эндпоинты добавляются по мере портирования экранов.
|
||
/// </summary>
|
||
public sealed class ApiClient
|
||
{
|
||
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = null };
|
||
|
||
private readonly HttpClient _http;
|
||
private readonly string _baseUrl;
|
||
private string? _token;
|
||
|
||
public ApiClient(string? baseUrl = null)
|
||
{
|
||
_baseUrl = (baseUrl ?? AppConfig.ApiBaseUrl).TrimEnd('/');
|
||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
|
||
}
|
||
|
||
public string BaseUrl => _baseUrl;
|
||
public string? Token => _token;
|
||
public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
|
||
|
||
public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
|
||
|
||
private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
|
||
|
||
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
|
||
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||
{
|
||
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
|
||
HttpResponseMessage resp;
|
||
try
|
||
{
|
||
resp = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
||
{
|
||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||
}).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
|
||
|
||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||
throw new ApiException("Неверный логин или пароль.");
|
||
if (resp.StatusCode == (HttpStatusCode)429)
|
||
throw new ApiException("Слишком много запросов. Подождите немного и повторите.");
|
||
if (!resp.IsSuccessStatusCode)
|
||
throw new ApiException(TryError(text) ?? $"Ошибка авторизации ({(int)resp.StatusCode}).");
|
||
|
||
var login = JsonSerializer.Deserialize<LoginResponse>(text, Json);
|
||
if (login is null || string.IsNullOrWhiteSpace(login.Token))
|
||
throw new ApiException("Сервер вернул пустой токен.");
|
||
_token = login.Token;
|
||
return login;
|
||
}
|
||
|
||
/// <summary>Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы).</summary>
|
||
public async Task<List<BuyerReportLineDto>> GetBuyerReportAsync(DateTime from, DateTime to)
|
||
{
|
||
EnsureAuth();
|
||
var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
|
||
var text = await AuthorizedGetAsync(path, "формирование отчёта").ConfigureAwait(false);
|
||
var resp = JsonSerializer.Deserialize<BuyerReportResponse>(text, Json);
|
||
return resp?.Lines ?? new List<BuyerReportLineDto>();
|
||
}
|
||
|
||
/// <summary>Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai).</summary>
|
||
public async Task<AiReportResult> GenerateAiReportAsync(string prompt, string format = "docx")
|
||
{
|
||
EnsureAuth();
|
||
if (string.IsNullOrWhiteSpace(prompt)) throw new ApiException("Пустой запрос для ИИ.");
|
||
var aiUrl = new Uri(new Uri(AppConfig.AiBaseUrl.TrimEnd('/') + "/"), "generate").ToString();
|
||
var body = JsonSerializer.Serialize(new AiReportRequest { Prompt = prompt, Token = _token!, Format = format }, Json);
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, aiUrl)
|
||
{
|
||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||
};
|
||
HttpResponseMessage resp;
|
||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||
catch (Exception ex) { throw new ApiException($"ИИ-сервис недоступен: {ex.Message}", ex); }
|
||
|
||
if (!resp.IsSuccessStatusCode)
|
||
{
|
||
var err = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||
throw new ApiException($"ИИ-сервис вернул {(int)resp.StatusCode}: {TryError(err) ?? err}");
|
||
}
|
||
var bytes = await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||
var name = resp.Content.Headers.ContentDisposition?.FileNameStar
|
||
?? resp.Content.Headers.ContentDisposition?.FileName
|
||
?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx");
|
||
return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
|
||
}
|
||
|
||
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
|
||
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0, string? priceListId = null)
|
||
{
|
||
EnsureAuth();
|
||
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
|
||
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
|
||
path += "&q=" + Uri.EscapeDataString(q.Trim());
|
||
if (!string.IsNullOrWhiteSpace(priceListId))
|
||
path += "&price_list_id=" + Uri.EscapeDataString(priceListId);
|
||
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
|
||
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
|
||
}
|
||
|
||
/// <summary>Прайс-листы, подключённые текущему покупателю (для выбора «Мои прайсы»).</summary>
|
||
public async Task<List<BuyerPriceListDto>> GetBuyerPriceListsAsync()
|
||
{
|
||
EnsureAuth();
|
||
var text = await AuthorizedGetAsync("/api/buyer/price-lists", "загрузку прайсов").ConfigureAwait(false);
|
||
try { return JsonSerializer.Deserialize<List<BuyerPriceListDto>>(text, Json) ?? new(); }
|
||
catch { return new(); }
|
||
}
|
||
|
||
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
|
||
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
|
||
{
|
||
EnsureAuth();
|
||
if (request.Items.Count == 0) throw new ApiException("Корзина пуста.");
|
||
var body = JsonSerializer.Serialize(request, Json);
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/api/buyer/orders"))
|
||
{
|
||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||
};
|
||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||
HttpResponseMessage resp;
|
||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||
catch (Exception ex) { throw new ApiException($"Не удалось отправить заказ: {ex.Message}", ex); }
|
||
|
||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
|
||
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка создания заказа ({(int)resp.StatusCode}).");
|
||
return JsonSerializer.Deserialize<BuyerOrderResponse>(text, Json) ?? new BuyerOrderResponse();
|
||
}
|
||
|
||
/// <summary>Резервирует следующий номер заказа EX-####### (как в WinForms до создания заказа).</summary>
|
||
public async Task<string?> NextGlobalSignAsync()
|
||
{
|
||
EnsureAuth();
|
||
var text = await AuthorizedGetAsync("/api/buyer/global-sign/next", "резервирование номера").ConfigureAwait(false);
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(text);
|
||
return doc.RootElement.TryGetProperty("global_sign", out var v) ? v.GetString() : null;
|
||
}
|
||
catch { return null; }
|
||
}
|
||
|
||
/// <summary>Адреса-грузополучатели покупателя.</summary>
|
||
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
|
||
{
|
||
EnsureAuth();
|
||
var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false);
|
||
try { return JsonSerializer.Deserialize<List<BuyerLocationDto>>(text, Json) ?? new(); }
|
||
catch { return new(); }
|
||
}
|
||
|
||
private async Task<string> AuthorizedGetAsync(string path, string op)
|
||
{
|
||
HttpResponseMessage resp;
|
||
try
|
||
{
|
||
resp = await SendWithRetryAsync(() =>
|
||
{
|
||
var r = new HttpRequestMessage(HttpMethod.Get, Url(path));
|
||
r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||
return r;
|
||
}).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
|
||
|
||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
|
||
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка сервера ({(int)resp.StatusCode}) при {op}.");
|
||
return text;
|
||
}
|
||
|
||
/// <summary>Отправляет запрос с повтором на транзиентных сбоях (холодный TLS,
|
||
/// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза.</summary>
|
||
private async Task<HttpResponseMessage> SendWithRetryAsync(Func<HttpRequestMessage> make, int retries = 2)
|
||
{
|
||
Exception? last = null;
|
||
for (int attempt = 0; attempt <= retries; attempt++)
|
||
{
|
||
using var req = make();
|
||
try { return await _http.SendAsync(req).ConfigureAwait(false); }
|
||
catch (HttpRequestException ex) { last = ex; } // сеть/TLS — повторяем
|
||
if (attempt < retries) await Task.Delay(250 * (attempt + 1)).ConfigureAwait(false);
|
||
}
|
||
throw last!;
|
||
}
|
||
|
||
private void EnsureAuth()
|
||
{
|
||
if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
|
||
}
|
||
|
||
private static string? TryError(string body)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||
try { return JsonSerializer.Deserialize<ErrorResponse>(body, Json)?.Error; }
|
||
catch { return null; }
|
||
}
|
||
}
|
||
|
||
public sealed class ApiException : Exception
|
||
{
|
||
public ApiException(string message, Exception? inner = null) : base(message, inner) { }
|
||
}
|