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 =>
|
||||
{
|
||||
var problems = ctx.ModelState
|
||||
// Технический лог — с сырыми путями/классами (видит только бэкенд).
|
||||
var raw = ctx.ModelState
|
||||
.Where(kv => kv.Value?.Errors.Count > 0)
|
||||
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
|
||||
.ToList();
|
||||
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("ModelValidation")
|
||||
.LogWarning("[Проверка] {Path}: {Problems}",
|
||||
ctx.HttpContext.Request.Path, string.Join("; ", problems));
|
||||
var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
|
||||
ctx.HttpContext.Request.Path, string.Join("; ", raw));
|
||||
|
||||
// Пользователю — дружелюбный русский без имён классов/путей/англ.
|
||||
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,
|
||||
Title = "Сервер не принял данные",
|
||||
Detail = string.Join("; ", problems.Take(3))
|
||||
+ (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
|
||||
Title = "Проверьте правильность заполнения",
|
||||
Detail = string.Join("; ", detailMsgs.Take(3))
|
||||
+ (detailMsgs.Count > 3 ? $" и ещё {detailMsgs.Count - 3}" : ""),
|
||||
};
|
||||
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. Добавление штрихкода к товару ───────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Add_barcode_creates_and_rejects_duplicate()
|
||||
|
|
|
|||
Loading…
Reference in a new issue