dev #872
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
||||
import useSWRMutation from 'swr/mutation'
|
||||
import { useAxios } from '../useAxios'
|
||||
import { unescapeString } from '@/util/common-utils'
|
||||
import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedModuleOptions'
|
||||
@ -9,7 +8,7 @@ import { compasDegAtom } from '@/store/orientationAtom'
|
||||
import { canvasState, currentCanvasPlanState } from '@/store/canvasAtom'
|
||||
import { POLYGON_TYPE } from '@/common/common'
|
||||
import { useCircuitTrestle } from '../useCirCuitTrestle'
|
||||
import { useContext } from 'react'
|
||||
import { useContext, useRef, useState } from 'react'
|
||||
import { addedRoofsState } from '@/store/settingAtom'
|
||||
import { roofsState } from '@/store/roofAtom'
|
||||
import { GlobalDataContext } from '@/app/GlobalDataProvider'
|
||||
@ -91,21 +90,49 @@ export function useCanvasPopupStatusController(param = 1) {
|
||||
|
||||
/**
|
||||
* 팝업 상태 저장
|
||||
* - 직전 in-flight 요청은 AbortController 로 취소 (slider/drag 연속 호출 시 마지막 값만 서버 도달)
|
||||
* - isMutating: 진행 중 여부 (버튼 disabled / 스피너 바인딩용)
|
||||
* - X-Idempotency-Key 는 useAxios 인터셉터에서 자동 부착
|
||||
*/
|
||||
const { trigger, isMutating } = useSWRMutation(
|
||||
`/api/v1/canvas-popup-status?objectNo=${currentCanvasPlan.objectNo}&planNo=${currentCanvasPlan.planNo}&popupType=${popupType}`,
|
||||
(url, { arg }) => {
|
||||
const params = {
|
||||
objectNo: currentCanvasPlan.objectNo,
|
||||
planNo: parseInt(currentCanvasPlan.planNo),
|
||||
popupType: popupType.toString(),
|
||||
// popupStatus: popupType === 1 ? arg : JSON.stringify(arg).replace(/"/g, '\"'),
|
||||
popupStatus: JSON.stringify(arg).replace(/"/g, '\"'),
|
||||
//hajebichi: arg.roofConstructions?.[0]?.addRoof?.hajebichi || '',
|
||||
}
|
||||
postFetcher(`/api/v1/canvas-popup-status`, params)
|
||||
},
|
||||
)
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
return { getModuleSelection, handleModuleSelectionTotal, trigger }
|
||||
// 언마운트 시 in-flight 는 의도적으로 abort 하지 않는다.
|
||||
// 이유: 사용자가 trigger 직후 즉시 모달을 닫는 race 에서 저장이 누락되는 회귀가 있었다 (기존 SWR fire-and-forget 시절엔 백엔드까지 도달).
|
||||
// in-flight Promise 는 다음 호출 시 새 controller 로 교체되거나, 자연 완료 후 GC 된다.
|
||||
|
||||
const trigger = async (arg) => {
|
||||
if (!currentCanvasPlan.objectNo || !currentCanvasPlan.planNo) return
|
||||
|
||||
// 직전 in-flight 취소
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort()
|
||||
}
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const params = {
|
||||
objectNo: currentCanvasPlan.objectNo,
|
||||
planNo: parseInt(currentCanvasPlan.planNo),
|
||||
popupType: popupType.toString(),
|
||||
popupStatus: JSON.stringify(arg).replace(/"/g, '\"'),
|
||||
}
|
||||
|
||||
setIsMutating(true)
|
||||
try {
|
||||
return await postFetcher(`/api/v1/canvas-popup-status`, params, { signal: controller.signal })
|
||||
} catch (e) {
|
||||
// AbortController 로 인한 취소는 정상 흐름
|
||||
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError') return
|
||||
// 기존 SWR fire-and-forget 호환: 서버 에러도 호출자에게 throw 하지 않음 (호출자 8곳 중 6곳이 await/try 미사용)
|
||||
console.error('🚀 ~ useCanvasPopupStatusController.trigger failed:', e)
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null
|
||||
setIsMutating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { getModuleSelection, handleModuleSelectionTotal, trigger, isMutating }
|
||||
}
|
||||
|
||||
@ -582,10 +582,9 @@ export function useCanvasSetting(executeEffect = true) {
|
||||
|
||||
/** 모듈 선택 데이터 초기화 */
|
||||
resetModuleSelectionData()
|
||||
//1번 초기화
|
||||
orientationTrigger({ compasDeg: 0, common: {}, module: {} })
|
||||
//2번 초기화
|
||||
moduleSelectedDataTrigger({ roofConstructions: [] })
|
||||
//1번/2번 초기화 — 동일 plan 의 popupType 다른 row 라도 같은 트랜잭션 가족 안에서 동시 UPDATE 가 SQL Server 데드락 victim 을 유발한 사례가 있어 직렬화한다.
|
||||
await orientationTrigger({ compasDeg: 0, common: {}, module: {} })
|
||||
await moduleSelectedDataTrigger({ roofConstructions: [] })
|
||||
const isModuleExist = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE)
|
||||
if (!isModuleExist) {
|
||||
resetSelectedModules()
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import axios, { Axios } from 'axios'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
const AxiosType = {
|
||||
INTERNAL: 'Internal',
|
||||
EXTERNAL: 'External',
|
||||
}
|
||||
|
||||
// WRITE 요청 식별용. 호출자가 option.headers['X-Idempotency-Key'] 를 직접 지정하면 그대로 사용.
|
||||
const WRITE_METHODS = ['post', 'put', 'patch']
|
||||
|
||||
/**
|
||||
* axios 인스턴스 생성 후 반환
|
||||
* @param {String} lang
|
||||
@ -26,10 +30,24 @@ export function useAxios(lang = '') {
|
||||
}
|
||||
url.startsWith('https') ? '' : (headerValue['lang'] = lang)
|
||||
|
||||
return axios.create({
|
||||
const instance = axios.create({
|
||||
baseURL: type === AxiosType.INTERNAL ? process.env.NEXT_PUBLIC_API_SERVER_PATH : '',
|
||||
headers: headerValue,
|
||||
})
|
||||
|
||||
// WRITE 메서드에 X-Idempotency-Key 자동 부착 (override 가능)
|
||||
instance.interceptors.request.use((config) => {
|
||||
const method = (config.method || '').toLowerCase()
|
||||
if (WRITE_METHODS.includes(method)) {
|
||||
config.headers = config.headers || {}
|
||||
if (!config.headers['X-Idempotency-Key']) {
|
||||
config.headers['X-Idempotency-Key'] = uuidv4()
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
// request 추가 로직
|
||||
@ -205,8 +223,8 @@ export function useAxios(lang = '') {
|
||||
return res
|
||||
}
|
||||
|
||||
const postFetcher = async (url, arg) => {
|
||||
const res = await post({ url, data: arg })
|
||||
const postFetcher = async (url, arg, option = {}) => {
|
||||
const res = await post({ url, data: arg, option })
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,10 @@ import { QcastContext } from '@/app/QcastProvider'
|
||||
import { unescapeString } from '@/util/common-utils'
|
||||
import { useTrestle } from '@/hooks/module/useTrestle'
|
||||
|
||||
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
|
||||
// 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort.
|
||||
const canvasStatusInflight = { controller: null }
|
||||
|
||||
/**
|
||||
* 플랜 처리 훅
|
||||
* 플랜을 표시하는 탭 UI 전반적인 처리 로직 관리
|
||||
@ -342,16 +346,30 @@ export function usePlan(params = {}) {
|
||||
canvasStatus: canvasToDbFormat(canvasStatus),
|
||||
}
|
||||
|
||||
await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData })
|
||||
// 직전 in-flight 가 살아있다면 취소. abort 된 호출은 .catch 에서 silent return.
|
||||
if (canvasStatusInflight.controller) {
|
||||
canvasStatusInflight.controller.abort()
|
||||
}
|
||||
const controller = new AbortController()
|
||||
canvasStatusInflight.controller = controller
|
||||
|
||||
await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData, option: { signal: controller.signal } })
|
||||
.then((res) => {
|
||||
setPlans((plans) => plans.map((plan) => (plan.id === currentCanvasPlan.id ? { ...plan, canvasStatus: canvasStatus } : plan)))
|
||||
if (saveAlert) swalFire({ text: getMessage('plan.message.save') })
|
||||
rtn = true
|
||||
})
|
||||
.catch((error) => {
|
||||
// AbortController 로 인한 취소는 정상 흐름 — 새 호출이 진행 중이므로 UX 메시지/로딩 해제 안 함
|
||||
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.name === 'AbortError') return
|
||||
swalFire({ text: error.message, icon: 'error' })
|
||||
setIsGlobalLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
if (canvasStatusInflight.controller === controller) {
|
||||
canvasStatusInflight.controller = null
|
||||
}
|
||||
})
|
||||
return rtn
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user