Девятый проход, по итогам приёмки 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
8.8 KiB
Контракты панели
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
потому ломались молча. Каждое из них закреплено тестом
(tools/test/frontend-*.test.ts) и гейтом приёмки.
Общее описание панели — 04-admin-panel.md.
1. Отрисовка иконок
Правило. Монохромная UI-иконка получает цвет ровно одним способом —
наследованием currentColor от компонента и темы. Ассет не содержит
литеральных цветов; цвет объявляется в CSS один раз, на .svg-icon.
Запрещено:
- литеральный
fill/stroke/stop-colorв монохромном ассете; - цвет в инлайновом
styleвнутри ассета; - непустой
<style>внутри ассета — его селекторы глобальны и красят чужие иконки; - растровое
<image>— оно не подчиняетсяcurrentColorникогда; - CSS-фильтр на
.svg-icon; - селектор по имени конкретной иконки (
[icon-class="…"]); - передача цвета параметром компонента.
Многоцветные ассеты объявляются явным списком MULTICOLOR_ICONS в
SvgIcon/symbol.ts. Их палитра — часть ассета, и проверка цвета к ним не
применяется. «Многоцветность» обязана быть решением, а не следствием того, что
иконку скачали с готовыми значениями fill.
Система координат. У каждого ассета обязан быть viewBox либо пара
width/height, из которой он синтезируется. Без неё <use> рисует иконку в
натуральную величину и обрезает её по размеру родительского <svg>.
Почему цвета не переписываются в рантайме. Источник истины — файл. Молчаливая
нормализация при сборке спрайта скрывала бы ровно тот дефект, который контракт
обязан делать видимым: добавленная с чёрным fill иконка выглядела бы
правильно и оставалась бы сломанной в исходниках.
Что машина не докажет. Фактический цвет на экране. Визуальная проверка светлой и тёмной темы, состояний hover/active и свёрнутого меню остаётся ручной и фиксируется в отчёте приёмки.
2. Структурированные ошибки
Правило. Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю — ещё и имя поля.
Форма ответа:
{
"code": 50001,
"type": "no",
"message": "проверка данных не пройдена",
"errors": [
{ "code": "min_length", "field": "secret", "message": "…", "params": { "min": "6" } }
],
"data": null
}
code— числовой код ответа (model/constant/code.go);errors[].code— причина (model/constant/error.go,ErrCode*);errors[].field— имя поля из JSON-тега; пусто для отказов уровня операции;errors[].params— числа правила, чтобы панель не заводила их вторую копию;message— человекочитаемый ответ для клиента без UI и запасной вариант для кода, которого панель ещё не знает.
Границы числа и границы длины строки различаются кодом (min против
min_length), хотя тег валидатора у них один: оператору это разные фразы.
Запрещено:
- выводить код ответа сравнением текста сообщения;
- отдавать обобщённое
invalidвместо описания полей; - разбирать сообщение сервера на стороне панели.
Локализация строится по ключу error.code.<код> с параметрами правила.
Наборы ключей ru и en обязаны совпадать: забытый ключ не ломает ни типы, ни
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит error.code.min_length
вместо фразы.
Состояние сессии сообщается кодами unauthorized, session_expired,
token_invalid, account_disabled при code = 50401. Панель по ним
показывает диалог и возвращает на форму входа.
Вход администратора. Неверные учётные данные всегда дают один код
invalid_credentials: «такого администратора нет» и «пароль не тот» обязаны
быть неразличимы снаружи. Отказ хранилища при этом остаётся системной ошибкой —
выдавать «неверный логин или пароль» при недоступной базе значит отправить
оператора искать несуществующую опечатку.
3. Необязательные поля и генерация секретов
Правило. Поле, объявленное необязательным, обязано принимать три неразличимых состояния: отсутствует, пустая строка, одни пробелы.
Это НЕ следует автоматически из тега omitempty. В go-playground/validator
он не пропускает правило, если поле объявлено указателем и указатель не nil —
hasValue считает указатель на пустую строку «значением». Поэтому DTO,
у которых есть необязательные строковые поля, реализуют dto.Normalizable, и
слой контроллеров вызывает Normalize() между разбором тела и проверкой
правил.
Граница проходит по каждому полю отдельно. Общее правило «пусто → не задано» молча ломает смысл:
| Поле | Пустое значение означает |
|---|---|
secret при создании |
сгенерировать |
secret при изменении |
не менять |
name при изменении |
не менять |
remark |
убрать пометку |
disabled = 0 |
включён |
quotaBytes = 0 |
нулевая квота |
Генерация секрета принадлежит серверу. Панель, подставляющая значение в
пустое поле, выполняла бы обещание «сгенерируем автоматически» ровно для одной
двери из четырёх: остаются прямой вызов API, импорт и будущие клиенты.
Единственная реализация — service.GeneratePeerSecret, на базе
util.RandomString (crypto/rand с отбрасыванием смещённых байтов). Тот же
вызов используется импортом.
Имя пира проверяется одним правилом service.IsValidPeerName на весь
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
разного.
4. Атрибуция
Адрес атрибуции объявлен один раз в apps/frontend/src/constants/branding.ts и
принадлежит приложению. Он не является операторской настройкой: ни hy2xs.env,
ни config API, ни таблица config, ни настройки панели его не содержат и не
могут переопределить.