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
+111
View File
@@ -0,0 +1,111 @@
/**
* Разбор структурированного отказа API.
*
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
* не имел ни одного способа узнать причину.
*
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
*/
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
export const API_CODE = {
success: 20000,
systemError: 50000,
validationFailed: 50001,
unauthorized: 50401,
forbidden: 50403,
} as const;
/**
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
*
* Перечислены только те, на которые панель реагирует по-разному. Остальные
* доезжают до оператора сообщением сервера.
*/
export const ERR_CODE = {
bodyInvalid: "body_invalid",
validationFailed: "validation_failed",
required: "required",
min: "min",
max: "max",
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
// разные фразы для оператора.
minLength: "min_length",
maxLength: "max_length",
len: "len",
oneOf: "oneof",
greaterThan: "gt",
ruleViolated: "rule_violated",
peerName: "peer_name",
credentialFormat: "credential_format",
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
invalidCredentials: "invalid_credentials",
importFileExtension: "import_file_extension",
unauthorized: "unauthorized",
sessionExpired: "session_expired",
tokenInvalid: "token_invalid",
accountDisabled: "account_disabled",
} as const;
export interface ApiFieldError {
code: string;
field?: string;
message: string;
params?: Record<string, string>;
}
export interface ApiErrorPayload {
code: number;
message?: string;
errors?: ApiFieldError[];
}
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
export class ApiError extends Error {
readonly code: number;
readonly errors: ApiFieldError[];
constructor(payload: ApiErrorPayload) {
super(payload.message || "Error");
this.name = "ApiError";
this.code = payload.code;
this.errors = payload.errors ?? [];
}
/** Причины, привязанные к полям формы. */
fieldErrors(): ApiFieldError[] {
return this.errors.filter((item) => !!item.field);
}
/** Первая причина без привязки к полю — отказ уровня операции. */
operationError(): ApiFieldError | undefined {
return this.errors.find((item) => !item.field);
}
hasCode(code: string): boolean {
return this.errors.some((item) => item.code === code);
}
get requiresSignIn(): boolean {
return this.code === API_CODE.unauthorized;
}
get sessionExpired(): boolean {
return (
this.hasCode(ERR_CODE.sessionExpired) ||
this.hasCode(ERR_CODE.accountDisabled)
);
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}