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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user