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:
nns 2026-10-09 09:52:33 +05:00
parent 193ba49ecb
commit 4aceda3e41
3 changed files with 164 additions and 6 deletions

View file

@ -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;
}
}

View file

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

View file

@ -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()