fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage)

This commit is contained in:
Александр Зимин
2026-05-10 16:22:35 +00:00
parent f2614244bf
commit fc2fb35717
8 changed files with 292 additions and 72 deletions
+56 -5
View File
@@ -1,3 +1,4 @@
import { useCallback, useRef } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
apiClient,
@@ -16,11 +17,49 @@ import {
type WebhookTestPingResult,
} from './client'
const idempotencyKey = () =>
/**
* Generate UUID v4 (или fallback). Раньше вызывался **per mutate** в каждой
* useCreateRecord/useUpdateRecord — двойной submit формы (Enter + click) давал
* два разных ключа → server создавал два состояния вместо идемпотентного
* deduping. Теперь caller генерирует ключ один раз на форму через
* {@link useFormIdempotencyKey} и передаёт его mutation'у.
*/
export const generateIdempotencyKey = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
/**
* Stable idempotency key для жизненного цикла одной формы.
*
* <p>Возвращает {@code [key, reset]}. {@code key} стабилен между ререндерами;
* {@code reset()} генерирует новый — вызывается на onSuccess чтобы следующая
* форма (например, edit того же record после save) получила свежий ключ.
*
* <p>Защита от двойного submit (Enter + click за одну сессию формы): оба
* запроса несут один и тот же {@code Idempotency-Key} → backend deduplicate'ит
* на втором.
*
* <p><b>Usage:</b>
* <pre>{@code
* const [idempotencyKey, resetIdempotencyKey] = useFormIdempotencyKey()
* const createMut = useCreateRecord(name)
* // submit:
* createMut.mutate(
* { payload: req, idempotencyKey },
* { onSuccess: () => { resetIdempotencyKey(); closeModal() } },
* )
* }</pre>
*/
export const useFormIdempotencyKey = (): [string, () => void] => {
const ref = useRef<string | null>(null)
if (ref.current === null) ref.current = generateIdempotencyKey()
const reset = useCallback(() => {
ref.current = generateIdempotencyKey()
}, [])
return [ref.current, reset]
}
export const useCreateDictionary = () => {
const qc = useQueryClient()
return useMutation({
@@ -54,14 +93,22 @@ export const useUpdateDictionary = () => {
})
}
/**
* Create-record mutation. {@code idempotencyKey} — required, передаётся caller'ом
* (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от
* двойного submit (Enter + click): backend deduplicate'ит по тому же ключу.
*/
export const useCreateRecord = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (req: CreateRecordRequest): Promise<RecordResponse> => {
mutationFn: async (params: {
payload: CreateRecordRequest
idempotencyKey: string
}): Promise<RecordResponse> => {
const { data } = await apiClient.post<RecordResponse>(
`/dictionaries/${dictionaryName}/records`,
req,
{ headers: { 'Idempotency-Key': idempotencyKey() } },
params.payload,
{ headers: { 'Idempotency-Key': params.idempotencyKey } },
)
return data
},
@@ -71,17 +118,21 @@ export const useCreateRecord = (dictionaryName: string) => {
})
}
/**
* Update-record mutation. См. {@link useCreateRecord} для idempotency rationale.
*/
export const useUpdateRecord = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
businessKey: string
payload: CreateRecordRequest
idempotencyKey: string
}): Promise<RecordResponse> => {
const { data } = await apiClient.put<RecordResponse>(
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`,
params.payload,
{ headers: { 'Idempotency-Key': idempotencyKey() } },
{ headers: { 'Idempotency-Key': params.idempotencyKey } },
)
return data
},