dev #872

Merged
ysCha merged 111 commits from dev into dev-deploy 2026-05-29 16:09:40 +09:00
4 changed files with 87 additions and 25 deletions
Showing only changes of commit efca023f1d - Show all commits

View File

@ -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 }
}

View File

@ -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()

View File

@ -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
}

View File

@ -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
}