- Модели (товар/корзина/строка/смена), парсер весовых ШК (EAN-13 20–29), корзина (штучный инкремент, весовой, скидки строка/чек с клампом), математика оплаты и смены (должно/факт/разница). - PosStore на raw Microsoft.Data.Sqlite (быстрый старт, AOT-дружелюбно): каталог + штрихкоды + очередь продаж + kv; индексы по штрихкоду/NameLower (кириллица не сворачивается через LIKE) и barcodes.ProductId (дельта-синк O(n), не O(n²)). - SyncService: дельта-pull каталога + push очереди с детерминированным idempotency-ключом (MD5 отсортированных ClientSaleId → один набор = один ключ). - Auth: password/refresh grant, ITokenStore (FileTokenStore 0600 на Unix, InMemory для тестов). Checkout.BuildSale: сборка серверного чека, скидка на чек распределяется по строкам (сервер понимает только построчную). UpdateCheck. - Тесты: штрихкод/корзина/оплата/смена/стор/синк-ключ/checkout/офлайн(5 без дублей)/версия — 37 зелёных. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
66 lines
2.8 KiB
C#
66 lines
2.8 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
|
||
namespace foodmarket.Pos.Core.Auth;
|
||
|
||
public record AuthTokens(string AccessToken, string RefreshToken, int ExpiresIn);
|
||
|
||
/// <summary>Вход кассира (password grant) и обновление токена (refresh) против
|
||
/// OpenIddict-сервера food-market. Касса использует тот же client_id,
|
||
/// что и веб. PIN поддерживается как пароль кассира (PIN = пароль учётки).</summary>
|
||
public sealed class AuthService
|
||
{
|
||
private readonly HttpClient _http;
|
||
public AuthService(HttpClient http) { _http = http; }
|
||
|
||
public async Task<AuthTokens> LoginAsync(string serverUrl, string username, string password, CancellationToken ct = default)
|
||
{
|
||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||
{
|
||
["grant_type"] = "password",
|
||
["username"] = username,
|
||
["password"] = password,
|
||
["client_id"] = "food-market-web",
|
||
["scope"] = "openid profile email roles api offline_access",
|
||
});
|
||
using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct);
|
||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||
if (!resp.IsSuccessStatusCode)
|
||
throw new AuthException(DescribeError(body) ?? $"Вход не удался ({(int)resp.StatusCode})");
|
||
return Parse(body);
|
||
}
|
||
|
||
public async Task<AuthTokens> RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default)
|
||
{
|
||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||
{
|
||
["grant_type"] = "refresh_token",
|
||
["refresh_token"] = refreshToken,
|
||
["client_id"] = "food-market-web",
|
||
});
|
||
using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct);
|
||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||
if (!resp.IsSuccessStatusCode)
|
||
throw new AuthException("Сессия истекла — войдите снова.");
|
||
return Parse(body);
|
||
}
|
||
|
||
private static AuthTokens Parse(string body)
|
||
{
|
||
using var doc = JsonDocument.Parse(body);
|
||
var r = doc.RootElement;
|
||
return new AuthTokens(
|
||
r.GetProperty("access_token").GetString()!,
|
||
r.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : "",
|
||
r.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600);
|
||
}
|
||
|
||
private static string? DescribeError(string body)
|
||
{
|
||
try { using var d = JsonDocument.Parse(body); return d.RootElement.TryGetProperty("error_description", out var e) ? e.GetString() : null; }
|
||
catch { return null; }
|
||
}
|
||
}
|
||
|
||
public sealed class AuthException : Exception { public AuthException(string m) : base(m) { } }
|