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:
@@ -0,0 +1,142 @@
|
||||
# Контракты панели
|
||||
|
||||
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||
потому ломались молча. Каждое из них закреплено тестом
|
||||
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
|
||||
|
||||
Общее описание панели — [04-admin-panel.md](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. Структурированные ошибки
|
||||
|
||||
**Правило.** Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю
|
||||
— ещё и имя поля.
|
||||
|
||||
Форма ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"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`, ни настройки панели его не содержат и не
|
||||
могут переопределить.
|
||||
Reference in New Issue
Block a user