fix(errors): дружелюбные русские ошибки ввода вместо технических JSON-текстов
Неверный тип/enum в теле запроса давал пользователю технический англ. текст с именем класса и путём ($.reason: The JSON value could not be converted to foodmarket.Api…LossInput…). Теперь InvalidModelStateResponseFactory прогоняет ModelState через ModelErrorTranslator: технические сообщения → «Неверное значение поля «Причина»» (имя поля по-русски, без классов/путей/англ.), осмысленные FluentValidation-сообщения остаются как есть. Сырой текст пишется только в лог бэкенда. Действует на web (toast) и mobile API. Тест: списание с неверным enum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
193ba49ecb
commit
4aceda3e41
|
|
@ -0,0 +1,123 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Validation;
|
||||||
|
|
||||||
|
/// <summary>Переводит технические ошибки разбора тела запроса (model binding /
|
||||||
|
/// JSON-десериализация) в дружелюбный русский. ASP.NET для неверного типа/enum
|
||||||
|
/// отдаёт тексты вида «The JSON value could not be converted to
|
||||||
|
/// foodmarket.Api.Controllers.Inventory.LossesController+LossInput. Path: $.reason …»
|
||||||
|
/// — показывать их пользователю нельзя (имена классов, пути, англ.). Заменяем на
|
||||||
|
/// «Неверное значение поля «Причина»». Сообщения FluentValidation (уже осмысленные,
|
||||||
|
/// русские) оставляем как есть.</summary>
|
||||||
|
public static class ModelErrorTranslator
|
||||||
|
{
|
||||||
|
// Маркеры технических сообщений от System.Text.Json / model binder.
|
||||||
|
private static readonly string[] TechnicalMarkers =
|
||||||
|
{
|
||||||
|
"could not be converted",
|
||||||
|
"JSON value",
|
||||||
|
"is not a valid",
|
||||||
|
"could not be parsed",
|
||||||
|
"The input was not valid",
|
||||||
|
"The supplied value is invalid",
|
||||||
|
"is not valid for",
|
||||||
|
"Cannot deserialize",
|
||||||
|
"A non-empty request body is required",
|
||||||
|
"The dto field is required",
|
||||||
|
"could not convert",
|
||||||
|
"Could not convert",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Человеческие названия частых полей (ключ — последний сегмент пути, lower).
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> FieldLabels =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["reason"] = "Причина",
|
||||||
|
["quantity"] = "Количество",
|
||||||
|
["qty"] = "Количество",
|
||||||
|
["unitcost"] = "Себестоимость",
|
||||||
|
["unitprice"] = "Цена",
|
||||||
|
["price"] = "Цена",
|
||||||
|
["amount"] = "Сумма",
|
||||||
|
["sum"] = "Сумма",
|
||||||
|
["date"] = "Дата",
|
||||||
|
["storeid"] = "Склад",
|
||||||
|
["productid"] = "Товар",
|
||||||
|
["counterpartyid"] = "Контрагент",
|
||||||
|
["customerid"] = "Покупатель",
|
||||||
|
["supplierid"] = "Поставщик",
|
||||||
|
["currencyid"] = "Валюта",
|
||||||
|
["discount"] = "Скидка",
|
||||||
|
["vatpercent"] = "НДС",
|
||||||
|
["vat"] = "НДС",
|
||||||
|
["payment"] = "Оплата",
|
||||||
|
["paidcash"] = "Оплата наличными",
|
||||||
|
["paidcard"] = "Оплата картой",
|
||||||
|
["type"] = "Тип",
|
||||||
|
["status"] = "Статус",
|
||||||
|
["packaging"] = "Фасовка",
|
||||||
|
["kind"] = "Вид",
|
||||||
|
["email"] = "Email",
|
||||||
|
["phone"] = "Телефон",
|
||||||
|
["name"] = "Название",
|
||||||
|
["article"] = "Артикул",
|
||||||
|
["barcode"] = "Штрихкод",
|
||||||
|
["code"] = "Код",
|
||||||
|
["plan"] = "Тариф",
|
||||||
|
["role"] = "Роль",
|
||||||
|
["lines"] = "Позиции",
|
||||||
|
["items"] = "Позиции",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsTechnical(string msg)
|
||||||
|
=> !string.IsNullOrEmpty(msg) && TechnicalMarkers.Any(m => msg.Contains(m, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>Человеческое имя поля из ключа ModelState («$.reason», «lines[0].reason»,
|
||||||
|
/// «dto.Amount» → «Причина» / «Сумма»; неизвестное — очищенный ключ в кавычках).</summary>
|
||||||
|
public static string FieldLabel(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || key == "$") return "данные запроса";
|
||||||
|
var seg = key.TrimStart('$', '.');
|
||||||
|
// последний сегмент пути без индексов массива
|
||||||
|
var lastDot = seg.LastIndexOf('.');
|
||||||
|
if (lastDot >= 0) seg = seg[(lastDot + 1)..];
|
||||||
|
var bracket = seg.IndexOf('[');
|
||||||
|
if (bracket >= 0) seg = seg[..bracket];
|
||||||
|
seg = seg.Trim();
|
||||||
|
if (seg.Length == 0) return "данные запроса";
|
||||||
|
return FieldLabels.TryGetValue(seg, out var label) ? label : seg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Переводит одно сообщение. Техническое → «Неверное значение поля «…»»;
|
||||||
|
/// осмысленное (FluentValidation) — без изменений.</summary>
|
||||||
|
public static string Translate(string key, string message)
|
||||||
|
=> IsTechnical(message)
|
||||||
|
? $"Неверное значение поля «{FieldLabel(key)}»"
|
||||||
|
: message;
|
||||||
|
|
||||||
|
/// <summary>Строит словарь дружелюбных ошибок по полям (ключ — очищенное имя поля)
|
||||||
|
/// из ModelState. Технические сообщения переведены, имена классов/пути убраны.</summary>
|
||||||
|
public static Dictionary<string, string[]> Humanize(ModelStateDictionary modelState)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string[]>();
|
||||||
|
foreach (var kv in modelState)
|
||||||
|
{
|
||||||
|
if (kv.Value is null || kv.Value.Errors.Count == 0) continue;
|
||||||
|
var field = CleanFieldKey(kv.Key);
|
||||||
|
var msgs = kv.Value.Errors
|
||||||
|
.Select(e => Translate(kv.Key, string.IsNullOrWhiteSpace(e.ErrorMessage)
|
||||||
|
? $"Неверное значение поля «{FieldLabel(kv.Key)}»" : e.ErrorMessage))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
result[field] = msgs;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CleanFieldKey(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || key == "$") return "";
|
||||||
|
var seg = key.TrimStart('$', '.');
|
||||||
|
return seg.Length == 0 ? "" : seg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -337,20 +337,25 @@ static string ApplyDefaultPoolConfig(string? raw)
|
||||||
// «за какое поле отвалилось» без детализации в теле ответа.
|
// «за какое поле отвалилось» без детализации в теле ответа.
|
||||||
o.InvalidModelStateResponseFactory = ctx =>
|
o.InvalidModelStateResponseFactory = ctx =>
|
||||||
{
|
{
|
||||||
var problems = ctx.ModelState
|
// Технический лог — с сырыми путями/классами (видит только бэкенд).
|
||||||
|
var raw = ctx.ModelState
|
||||||
.Where(kv => kv.Value?.Errors.Count > 0)
|
.Where(kv => kv.Value?.Errors.Count > 0)
|
||||||
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
|
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
|
||||||
.ToList();
|
.ToList();
|
||||||
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger("ModelValidation")
|
.CreateLogger("ModelValidation")
|
||||||
.LogWarning("[Проверка] {Path}: {Problems}",
|
.LogWarning("[Проверка] {Path}: {Problems}",
|
||||||
ctx.HttpContext.Request.Path, string.Join("; ", problems));
|
ctx.HttpContext.Request.Path, string.Join("; ", raw));
|
||||||
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
|
|
||||||
|
// Пользователю — дружелюбный русский без имён классов/путей/англ.
|
||||||
|
var friendly = foodmarket.Api.Infrastructure.Validation.ModelErrorTranslator.Humanize(ctx.ModelState);
|
||||||
|
var detailMsgs = friendly.Values.SelectMany(v => v).Distinct().ToList();
|
||||||
|
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(friendly)
|
||||||
{
|
{
|
||||||
Status = StatusCodes.Status400BadRequest,
|
Status = StatusCodes.Status400BadRequest,
|
||||||
Title = "Сервер не принял данные",
|
Title = "Проверьте правильность заполнения",
|
||||||
Detail = string.Join("; ", problems.Take(3))
|
Detail = string.Join("; ", detailMsgs.Take(3))
|
||||||
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
|
+ (detailMsgs.Count > 3 ? $" и ещё {detailMsgs.Count - 3}" : ""),
|
||||||
};
|
};
|
||||||
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
|
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,36 @@ public async Task Stock_group_filter_with_includeZero_shows_group_products_witho
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 1d. Ошибки ввода — дружелюбный русский без имён классов/путей ──────
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_enum_value_returns_friendly_russian_without_class_names()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"mob-err-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
var pid = await api.CreateProductAsync(refs, $"Списываемый-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
// Списание с несуществующим значением enum «reason» → ранее приходил
|
||||||
|
// технический англ. текст с именем класса LossInput. Теперь — русский.
|
||||||
|
var json = System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
reason = "НЕвернаяПричина", notes = "x",
|
||||||
|
lines = new[] { new { productId = pid, quantity = 1m, unitCost = 10m } },
|
||||||
|
});
|
||||||
|
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||||
|
using var resp = await api.Http.PostAsync("/api/inventory/losses", content);
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
body.Should().NotContain("foodmarket.", "в ответе не должно быть имён классов");
|
||||||
|
body.Should().NotContain("JSON value", "технический англ. текст недопустим");
|
||||||
|
body.Should().NotContain("could not be converted");
|
||||||
|
body.Should().NotContain("$.", "пути JSON не показываем пользователю");
|
||||||
|
body.Should().Contain("Причина", "поле названо по-русски");
|
||||||
|
body.Should().MatchRegex("[Нн]еверное значение", "дружелюбная формулировка");
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
|
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_barcode_creates_and_rejects_duplicate()
|
public async Task Add_barcode_creates_and_rejects_duplicate()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue