food-market/src/food-market.pos.core/Auth/AuthService.cs
nns 2b63bc9c66 feat(pos-core): логика кассы без UI + 37 unit-тестов
- Модели (товар/корзина/строка/смена), парсер весовых ШК (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>
2026-10-08 04:58:22 +05:00

66 lines
2.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) { } }