From 436bbb8bfba7fcf7262b6c1514c9eb6eb7903bb9 Mon Sep 17 00:00:00 2001 From: Exest Date: Thu, 13 Aug 2026 09:23:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=202.0.3:?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0,=20=D0=B1=D1=8B=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B9=D1=81=D0=B0,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D1=82=D0=BE=D1=80=20=D0=BF=D1=80=D0=B8=20SSL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session: сохранение/восстановление сессии между запусками (JWT exp-проверка) - LoadPrice: страница 20000 вместо 500 — прайс грузится одним запросом - ApiClient: повтор запроса на транзиентных сбоях TLS (поиск/загрузка) - download-страница: реальный размер, macOS помечен «скоро» Co-Authored-By: Claude Opus 4.8 --- installer/ElfisaPharmacy.iss | 2 +- src/Elfisa.Avalonia/Elfisa.Avalonia.csproj | 6 +-- .../ViewModels/LoadPriceViewModel.cs | 6 ++- .../ViewModels/RootViewModel.cs | 12 ++++- src/Elfisa.Core/ApiClient.cs | 39 +++++++++++--- src/Elfisa.Core/Session.cs | 52 ++++++++++++++++++- web/download/index.html | 26 +++++----- 7 files changed, 115 insertions(+), 28 deletions(-) diff --git a/installer/ElfisaPharmacy.iss b/installer/ElfisaPharmacy.iss index 02896a4..6bdad70 100644 --- a/installer/ElfisaPharmacy.iss +++ b/installer/ElfisaPharmacy.iss @@ -3,7 +3,7 @@ #define MyAppName "Электронная Фармация" #define MyAppBrand "ЭльФиСА" -#define MyAppVersion "2.0.2" +#define MyAppVersion "2.0.3" #define MyAppPublisher "PharmData" #define MyAppURL "https://cdn.24pharmdata.ru" #define MyAppSupportURL "https://24pharmdata.ru" diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj index 714ff39..bb8e0fe 100644 --- a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj +++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj @@ -5,9 +5,9 @@ enable app.manifest true - 2.0.2 - 2.0.2.0 - 2.0.2.0 + 2.0.3 + 2.0.3.0 + 2.0.3.0 ЭльФиСА — Электронная Фармация PharmData diff --git a/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs b/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs index aa5f9c9..c14baad 100644 --- a/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs +++ b/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs @@ -61,7 +61,9 @@ public partial class LoadPriceViewModel : ViewModelBase ProgressText = "Соединение с сервером…"; var all = new List(); - const int page = 500; + // Большая страница: сервер на каждый запрос заново прогоняет тяжёлый + // сводный запрос, поэтому одна большая страница в разы быстрее десятков мелких. + const int page = 20000; int offset = 0, total = 0; while (true) @@ -77,7 +79,7 @@ public partial class LoadPriceViewModel : ViewModelBase : $"Загружено {all.Count} позиций…"; offset += batch.Count; - if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000) + if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 500000) break; } diff --git a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs index 6016726..770bb19 100644 --- a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs +++ b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs @@ -15,6 +15,12 @@ public partial class RootViewModel : ViewModelBase public RootViewModel() { + // Запомнили вход в прошлый раз и токен ещё живой — сразу в программу. + if (Session.TryRestore()) + { + Current = new ShellViewModel(Logout); + return; + } Current = new LoginViewModel(OnLoggedIn); TryDevAutoLogin(); } @@ -38,7 +44,11 @@ public partial class RootViewModel : ViewModelBase catch { /* остаёмся на экране входа */ } } - private void OnLoggedIn() => Current = new ShellViewModel(Logout); + private void OnLoggedIn() + { + Session.Current.Save(); // запомнить вход между запусками + Current = new ShellViewModel(Logout); + } private void Logout() { diff --git a/src/Elfisa.Core/ApiClient.cs b/src/Elfisa.Core/ApiClient.cs index 0a8a6f8..c810fe6 100644 --- a/src/Elfisa.Core/ApiClient.cs +++ b/src/Elfisa.Core/ApiClient.cs @@ -37,12 +37,14 @@ public sealed class ApiClient public async Task LoginAsync(string username, string password) { var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json); - using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login")) - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; HttpResponseMessage resp; - try { resp = await _http.SendAsync(req).ConfigureAwait(false); } + 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); @@ -164,10 +166,16 @@ public sealed class ApiClient private async Task AuthorizedGetAsync(string path, string op) { - using var req = new HttpRequestMessage(HttpMethod.Get, Url(path)); - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); HttpResponseMessage resp; - try { resp = await _http.SendAsync(req).ConfigureAwait(false); } + 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); @@ -176,6 +184,21 @@ public sealed class ApiClient return text; } + /// Отправляет запрос с повтором на транзиентных сбоях (холодный TLS, + /// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза. + private async Task SendWithRetryAsync(Func 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("Требуется авторизация."); diff --git a/src/Elfisa.Core/Session.cs b/src/Elfisa.Core/Session.cs index 072b419..4c09845 100644 --- a/src/Elfisa.Core/Session.cs +++ b/src/Elfisa.Core/Session.cs @@ -1,6 +1,9 @@ +using System; +using System.Text.Json; + namespace Elfisa.Core; -/// Текущая сессия пользователя (токен, роль, логин). +/// Текущая сессия пользователя (токен, роль, логин). Сохраняется между запусками. public sealed class Session { public static Session Current { get; } = new(); @@ -11,10 +14,57 @@ public sealed class Session public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token); + /// Сохранить сессию в настройки (чтобы не логиниться каждый раз). + public void Save() + { + SettingsStore.Current.Token = Token; + SettingsStore.Current.Role = Role; + SettingsStore.Current.Username = Username; + SettingsStore.Save(); + } + + /// Восстановить сессию из настроек, если токен есть и не истёк. + public static bool TryRestore() + { + var s = SettingsStore.Current; + if (string.IsNullOrWhiteSpace(s.Token) || IsJwtExpired(s.Token)) + return false; + Current.Token = s.Token; + Current.Role = s.Role; + Current.Username = s.Username; + return true; + } + public void Clear() { Token = null; Role = null; Username = null; + SettingsStore.Current.Token = null; + SettingsStore.Current.Role = null; + SettingsStore.Current.Username = null; + SettingsStore.Save(); + } + + /// Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту. + private static bool IsJwtExpired(string token) + { + try + { + var parts = token.Split('.'); + if (parts.Length != 3) return true; + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: payload += "=="; break; + case 3: payload += "="; break; + } + var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var expUnix)) + return DateTimeOffset.UtcNow.ToUnixTimeSeconds() >= expUnix - 60; + return false; // нет exp — считаем валидным + } + catch { return true; } } } diff --git a/web/download/index.html b/web/download/index.html index ff9eec7..a8516bc 100644 --- a/web/download/index.html +++ b/web/download/index.html @@ -116,6 +116,8 @@ background:var(--brand-soft); color:var(--brand-ink); border:1px solid color-mix(in srgb,var(--brand) 26%, transparent)} .dl:hover{background:var(--brand); color:#fff} .dl svg{width:17px; height:17px; fill:currentColor} + .dl.soon{background:var(--panel-2); color:var(--muted); border-color:var(--line); cursor:default} + .dl.soon:hover{background:var(--panel-2); color:var(--muted)} /* ---- features ---- */ .feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px} @@ -173,7 +175,7 @@
Формат.exe установщик
СистемаWindows 10 / 11
Разрядность64-бит
-
Размер~90 МБ
+
Размер~48 МБ
@@ -185,17 +187,14 @@ Ваша система

macOS

-

Образ диска · Apple Silicon и Intel

+

Apple Silicon и Intel · готовим сборку

Формат.dmg образ
СистемаmacOS 12+
ПроцессорApple / Intel
-
Размер~95 МБ
+
Статусскоро
-
- - Скачать образ .dmg - + Скоро
@@ -207,7 +206,7 @@
Формат.AppImage
Системаglibc 2.31+
Разрядностьx86-64
-
Размер~95 МБ
+
Размер~38 МБ
@@ -234,9 +233,9 @@