fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+183 -17
View File
@@ -1,47 +1,213 @@
package controller
import (
"errors"
"fmt"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"net/http"
"regexp"
"hy2xs-admin/service"
)
var validate *validator.Validate
func init() {
validate = validator.New()
_ = validate.RegisterValidation("validateStr", validateStr)
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "" || name == "-" {
return field.Name
}
return name
})
mustRegister("peerName", validatePeerName)
mustRegister("credentialStr", validateCredentialStr)
}
func validateStr(f validator.FieldLevel) bool {
func mustRegister(tag string, fn validator.Func) {
if err := validate.RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
}
}
// validatePeerName — единственное правило имени пира.
//
// Набор символов и длина берутся из service: имя пира проверяется на двух
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
// независимые копии правила уже расходились. Копия в слое контроллеров
// выглядела так:
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// credentialStrPattern — набор символов логина и пароля администратора.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
// пира.
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
func validateCredentialStr(f validator.FieldLevel) bool {
field := f.Field().String()
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
return field == "" || credentialStrPattern.MatchString(field)
}
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
// правила.
//
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
// нарушение любого правила любого поля превращались в одну строку `invalid`:
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
// же и предлагала сделать.
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
var bindErr error
if c.Request.Method == http.MethodGet {
switch c.Request.Method {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field)
} else if c.Request.Method == http.MethodPost ||
c.Request.Method == http.MethodPut ||
c.Request.Method == http.MethodPatch ||
c.Request.Method == http.MethodDelete {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = c.ShouldBindJSON(&field)
}
if bindErr != nil {
vo.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
vo.FailValidation(
"запрос не разобран: проверьте формат и типы полей",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Message: bindErr.Error(),
}},
c,
)
return field, errors.New(constant.ErrCodeBodyInvalid)
}
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
// состояниями для валидатора и одинаковыми для человека.
if normalizable, ok := any(&field).(dto.Normalizable); ok {
normalizable.Normalize()
}
if err := validate.Struct(&field); err != nil {
vo.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
vo.FailValidation(
"проверка данных не пройдена",
describeValidationErrors(err),
c,
)
return field, errors.New(constant.ErrCodeValidationFailed)
}
return field, nil
}
// describeValidationErrors переводит отказ валидатора в список причин.
func describeValidationErrors(err error) []vo.FieldError {
var validationErrors validator.ValidationErrors
if !errors.As(err, &validationErrors) {
// InvalidValidationError означает ошибку программиста (в проверку
// передали не структуру), а не плохой вход оператора. Скрывать её за
// сообщением о поле нельзя: она никогда не чинится правкой формы.
return []vo.FieldError{{
Code: constant.ErrCodeValidationFailed,
Message: err.Error(),
}}
}
out := make([]vo.FieldError, 0, len(validationErrors))
for _, fieldErr := range validationErrors {
out = append(out, describeFieldError(fieldErr))
}
return out
}
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
// величину. Указатели валидатор к этому моменту уже разыменовал.
func isTextField(fieldErr validator.FieldError) bool {
return fieldErr.Kind() == reflect.String
}
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
field := fieldErr.Field()
param := fieldErr.Param()
described := vo.FieldError{Field: field}
switch fieldErr.Tag() {
case "required":
described.Code = constant.ErrCodeRequired
described.Message = fmt.Sprintf("поле %q обязательно", field)
case "min":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMinLength
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
break
}
described.Code = constant.ErrCodeMin
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
case "max":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMaxLength
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
break
}
described.Code = constant.ErrCodeMax
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
case "len":
described.Code = constant.ErrCodeLen
described.Params = map[string]string{"len": param}
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
case "oneof":
described.Code = constant.ErrCodeOneOf
described.Params = map[string]string{"values": param}
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
case "gt":
described.Code = constant.ErrCodeGreaterThan
described.Params = map[string]string{"gt": param}
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
case "peerName":
described.Code = constant.ErrCodePeerName
described.Params = map[string]string{
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
"charset": service.PeerNameCharset,
}
described.Message = fmt.Sprintf(
"имя пира: от %d до %d символов из набора %s",
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
)
case "credentialStr":
described.Code = constant.ErrCodeCredentialStr
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
}
return described
}