Files
founder c0a43ae915 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

88 lines
5.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dto
// Имя пира проверяется правилом `peerName`, которое несёт и набор символов, и
// длину.
//
// Раньше здесь стояло `min=1,max=32,validateStr`, где `validateStr` требовал
// 6-32 символа. Два правила на одном поле противоречили друг другу: имя из
// трёх символов проходило `min=1` и отказывалось на `validateStr`, а оператор
// видел «invalid» и подсказку «короткий идентификатор пира». Длина живёт
// внутри одного правила, чтобы такого расхождения больше не было.
type PeerPageDto struct {
BaseDto
Name *string `json:"name" form:"name" validate:"omitempty,max=32"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: очищенный фильтр — это отсутствие фильтра.
//
// Регрессия, которую это закрывает: `el-input` с крестиком очистки ставит
// пустую строку, axios сериализует её как `?name=`, и поиск пиров отказывал с
// «invalid» после нажатия на крестик.
func (d *PeerPageDto) Normalize() {
d.BaseDto.Normalize()
blankToNil(&d.Name)
blankToNil(&d.Remark)
}
type PeerSaveDto struct {
Name *string `json:"name" form:"name" validate:"required,peerName"`
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: пустой секрет означает «сгенерируй сам».
//
// Именно это обещает подпись под полем, и именно это умеет CreatePeer. Пустая
// пометка при этом остаётся пустой пометкой — «нет комментария» и «не менять
// комментарий» не одно и то же.
func (d *PeerSaveDto) Normalize() {
trimValue(d.Name)
blankToNil(&d.Secret)
trimValue(d.Remark)
}
type PeerUpdateDto struct {
// Id приходит из пути `/peers/:id`, а не из тела, поэтому здесь он
// НЕОБЯЗАТЕЛЕН.
//
// Раньше сюда встраивался IdDto с правилом `required,gt=0`, и тело запроса
// обязано было повторять идентификатор, уже указанный в адресе. Панель его
// повторяла, поэтому расхождение не проявлялось; любой другой клиент,
// сделавший PATCH /peers/7 без `"id": 7` в теле, получал отказ «поле id
// обязательно» — при том, что значение из тела всё равно затирается
// значением из пути.
Id *int64 `json:"id" form:"id" validate:"omitempty,gt=0"`
Name *string `json:"name" form:"name" validate:"omitempty,peerName"`
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
}
// Normalize: при изменении пустое имя и пустой секрет означают «не менять».
//
// Ровно так их и читает service.UpdatePeer (`!= nil && != ""`), поэтому
// приведение здесь не добавляет поведения, а убирает расхождение: без него
// правила отказывали на входе, который сервис считает законным.
//
// `remark` и `disabled` намеренно не трогаются: пустая пометка и ноль — это
// значения, которые оператор устанавливает осознанно.
func (d *PeerUpdateDto) Normalize() {
blankToNil(&d.Name)
blankToNil(&d.Secret)
trimValue(d.Remark)
}
type PeerKickDto struct {
BannedUntil *int64 `json:"bannedUntil" form:"bannedUntil" validate:"required,min=0"`
}