From 46877cc134458a6cc059af853ad5a72a9dbd47fc Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 8 May 2026 01:05:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(phone):=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=8F=20KZ-=D0=A4=D0=9B=D0=9A=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=D1=85=20endpoint'=D0=B0=D1=85=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?phone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст в Phone — серверной валидации ФЛК не было (только в /api/auth/signup). Это позволяло сохранить «abc» в Organization.Phone и невалидные номера для контрагентов и сотрудников. — Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz + IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»; валидно: 11 цифр, начинается с «77» (мобильный код KZ). — SuperAdminOrganizationsController.Create/Update: 400 если phone не парсится; в БД пишется нормализованная форма «+77001234567». — CounterpartiesController.Create/Update: то же. Apply() нормализует. — EmployeesController.Create/Update: то же. — SuperAdminEmployeesController.Create/Update: то же. — AuthSignupController: убран локальный NormalizeKzPhone, используется shared. Сообщение об ошибке унифицировано. Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone). Незаполненный phone остаётся валидным для опциональных полей — контроллер сам решает требовать или нет. --- .../Controllers/AuthSignupController.cs | 17 ++------- .../Catalog/CounterpartiesController.cs | 12 ++++++- .../Organizations/EmployeesController.cs | 5 ++- .../SuperAdminEmployeesController.cs | 10 ++++-- .../SuperAdminOrganizationsController.cs | 11 ++++-- .../Common/PhoneNormalization.cs | 35 +++++++++++++++++++ 6 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 src/food-market.application/Common/PhoneNormalization.cs diff --git a/src/food-market.api/Controllers/AuthSignupController.cs b/src/food-market.api/Controllers/AuthSignupController.cs index 4a0f67e..8fec3cf 100644 --- a/src/food-market.api/Controllers/AuthSignupController.cs +++ b/src/food-market.api/Controllers/AuthSignupController.cs @@ -31,19 +31,6 @@ public AuthSignupController(AppDbContext db, UserManager userMgr) public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan); public record SignupResult(Guid OrganizationId, string Email); - /// Нормализация и проверка телефона Казахстана. Принимаем любое - /// форматирование (пробелы, скобки, +, дефисы), оставляем только цифры, - /// ведущая «8» переписывается в «7». Валидно: ровно 11 цифр, начинается - /// с «77» — мобильный код KZ. «79…» (РФ) и прочие отвергаем. - private static string? NormalizeKzPhone(string? raw) - { - if (string.IsNullOrWhiteSpace(raw)) return null; - var digits = new string(raw.Where(char.IsDigit).ToArray()); - if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..]; - if (digits.Length != 11 || !digits.StartsWith("77")) return null; - return "+" + digits; - } - [HttpPost("signup")] public async Task> Signup([FromBody] SignupInput input, CancellationToken ct) { @@ -52,9 +39,9 @@ public async Task> Signup([FromBody] SignupInput inpu return BadRequest(new { error = "Email, пароль и название обязательны." }); if (input.Password.Length < 8) return BadRequest(new { error = "Пароль минимум 8 символов." }); - var normalizedPhone = NormalizeKzPhone(input.Phone); + var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); if (normalizedPhone is null) - return BadRequest(new { error = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67" }); + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); var existing = await _userMgr.FindByEmailAsync(input.Email); if (existing is not null) diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index 8edfe19..6ca8c87 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -72,6 +72,7 @@ public async Task> Get(Guid id, CancellationToken [HttpPost, Authorize(Roles = "Admin,Storekeeper")] public async Task> Create([FromBody] CounterpartyInput input, CancellationToken ct) { + if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); var e = Apply(new Counterparty(), input); _db.Counterparties.Add(e); await _db.SaveChangesAsync(ct); @@ -81,6 +82,7 @@ public async Task> Create([FromBody] CounterpartyI [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] public async Task Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct) { + if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); Apply(e, input); @@ -88,6 +90,14 @@ public async Task Update(Guid id, [FromBody] CounterpartyInput in return NoContent(); } + private static string? NormalizePhoneOrError(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + return foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(raw) + ? null + : foodmarket.Application.Common.PhoneNormalization.ErrorMessage; + } + [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] public async Task Delete(Guid id, CancellationToken ct) { @@ -108,7 +118,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i) e.TaxNumber = i.TaxNumber; e.CountryId = i.CountryId; e.Address = i.Address; - e.Phone = i.Phone; + e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone); e.Email = i.Email; e.BankName = i.BankName; e.BankAccount = i.BankAccount; diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index da5c0a4..f309166 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -217,7 +217,10 @@ public async Task Update(Guid id, [FromBody] EmployeeInput input, e.MiddleName = input.MiddleName; e.Position = input.Position; e.Email = input.Email; - e.Phone = input.Phone; + if (!string.IsNullOrWhiteSpace(input.Phone) + && !foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone)) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); e.Salary = input.Salary; e.TaxNumber = input.TaxNumber; e.Description = input.Description; diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs index 02a3337..15c5dec 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs @@ -168,11 +168,15 @@ public async Task> Create(Guid orgId, [FromBody] Cr newUserId = u.Id; } + if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone)) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + var emp = new Employee { OrganizationId = orgId, UserId = newUserId, LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName, - Position = input.Position, Email = input.Email, Phone = input.Phone, + Position = input.Position, Email = input.Email, + Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone), RoleId = input.RoleId, IsActive = input.IsActive, }; foreach (var rp in input.RetailPointIds ?? []) @@ -206,7 +210,9 @@ public async Task Update(Guid orgId, Guid id, [FromBody] UpdateIn e.MiddleName = input.Employee.MiddleName; e.Position = input.Employee.Position; e.Email = input.Employee.Email; - e.Phone = input.Employee.Phone; + if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Employee.Phone)) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Employee.Phone); e.Salary = input.Employee.Salary; e.TaxNumber = input.Employee.TaxNumber; e.Description = input.Employee.Description; diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs index 896cd3f..c7e0ed4 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -92,11 +92,15 @@ public async Task> Get(Guid id, CancellationToken ct) [HttpPost] public async Task> Create([FromBody] CreateOrgRequest input, CancellationToken ct) { + var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Org.Phone); + if (!string.IsNullOrEmpty(input.Org.Phone) && phone is null) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); + var org = new Organization { Name = input.Org.Name, CountryCode = input.Org.CountryCode, Bin = input.Org.Bin, Address = input.Org.Address, - Phone = input.Org.Phone, Email = input.Org.Email, + Phone = phone, Email = input.Org.Email, DefaultCurrencyId = input.Org.DefaultCurrencyId, }; _db.Organizations.Add(org); @@ -147,10 +151,13 @@ public async Task Update(Guid id, [FromBody] OrgInput input, Canc { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); + var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); + if (!string.IsNullOrEmpty(input.Phone) && phone is null) + return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}"; o.Name = input.Name; o.CountryCode = input.CountryCode; o.Bin = input.Bin; o.Address = input.Address; - o.Phone = input.Phone; o.Email = input.Email; + o.Phone = phone; o.Email = input.Email; o.DefaultCurrencyId = input.DefaultCurrencyId; await _db.SaveChangesAsync(ct); var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}"; diff --git a/src/food-market.application/Common/PhoneNormalization.cs b/src/food-market.application/Common/PhoneNormalization.cs new file mode 100644 index 0000000..91a8c71 --- /dev/null +++ b/src/food-market.application/Common/PhoneNormalization.cs @@ -0,0 +1,35 @@ +namespace foodmarket.Application.Common; + +/// Серверная валидация и нормализация телефонов Казахстана. +/// Принимает любое форматирование (+7 700 123-45-67, 8 (700) 1234567, ...), +/// оставляет только цифры, ведущая «8» переписывается в «7». +/// Валидно: ровно 11 цифр, начинается с «77» — мобильный код KZ. +/// «79…» (РФ) и прочие отвергаются. +/// +/// Используется на всех endpoint'ах, принимающих phone — defense-in-depth +/// к фронт-валидации (PhoneInput / validatePhone). +public static class PhoneNormalization +{ + public const string ErrorMessage = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67"; + + /// Возвращает нормализованный номер вида "+77001234567" если phone валиден, + /// null если не валиден. Пустой ввод возвращает null без ошибки — + /// проверку «обязательно ли» делает вызывающая сторона. + public static string? TryNormalizeKz(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var digits = new string(raw.Where(char.IsDigit).ToArray()); + if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..]; + if (digits.Length != 11 || !digits.StartsWith("77")) return null; + return "+" + digits; + } + + /// True если phone валиден или null/пусто (опциональное поле). + /// False — только если непустой ввод не парсится. Удобно для контроллеров, + /// которые принимают phone как опциональный. + public static bool IsValidOrEmpty(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return true; + return TryNormalizeKz(raw) is not null; + } +}