중간저장
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
* (main) 레이아웃 바깥이라 헤더/사이드바 없이 깔끔한 팝업 UI.
|
||||
*/
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { X } from 'lucide-react';
|
||||
@@ -25,7 +25,25 @@ import type { FieldConfig, Template } from '@/types/invyone-component';
|
||||
|
||||
export default function FormPopupPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-sm">로딩 중...</div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
<div className="border-border flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-sm font-semibold">불러오는 중...</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid animate-pulse grid-cols-2 gap-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="h-3 w-16 rounded bg-muted" />
|
||||
<div className="h-9 w-full rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormPopupContent />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -44,6 +62,9 @@ function FormPopupContent() {
|
||||
const [formRow, setFormRow] = useState<Record<string, any> | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 사용자가 폼을 편집하기 시작했는지 플래그 — 백그라운드 재검증이
|
||||
// template 을 교체해 현재 입력 UX 를 깨뜨리지 않도록 보호.
|
||||
const formRowDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) {
|
||||
@@ -52,11 +73,11 @@ function FormPopupContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 에서 부모가 넘겨준 초기 데이터 읽기 + 즉시 삭제 (1회성).
|
||||
// 여기서는 initialRow / primaryTable / templateName 만 받고 template 은
|
||||
// 항상 templateId 로 fresh fetch 한다. (부모 캐시는 views 가 stale 일 수 있음)
|
||||
// localStorage 에서 부모가 넘겨준 seed 읽기 + 즉시 삭제 (1회성).
|
||||
// template 이 함께 넘어오면 즉시 렌더 후 백그라운드에서 재검증.
|
||||
let seededName = '';
|
||||
let seededTable = '';
|
||||
let seededTemplate: any = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(`form-popup:${key}`);
|
||||
if (raw) {
|
||||
@@ -70,6 +91,7 @@ function FormPopupContent() {
|
||||
seededName = String(data.templateName);
|
||||
setTemplateName(seededName);
|
||||
}
|
||||
if (data.template) seededTemplate = data.template;
|
||||
localStorage.removeItem(`form-popup:${key}`);
|
||||
} else {
|
||||
setFormRow({});
|
||||
@@ -78,49 +100,49 @@ function FormPopupContent() {
|
||||
setFormRow({});
|
||||
}
|
||||
|
||||
// 1) seed 가 있으면 즉시 렌더 상태로 전환 (로딩 플래시 제거)
|
||||
if (seededTemplate) {
|
||||
setTemplate(seededTemplate as Template);
|
||||
if (Array.isArray(seededTemplate.fields)) {
|
||||
setFields(seededTemplate.fields as FieldConfig[]);
|
||||
}
|
||||
if (!seededName) setTemplateName(seededTemplate.name ?? '');
|
||||
if (!seededTable) {
|
||||
setPrimaryTable(
|
||||
seededTemplate.primary_table ?? seededTemplate.PRIMARY_TABLE ?? '',
|
||||
);
|
||||
}
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
// 2) 항상 백그라운드 재검증 — seed 가 stale 일 수 있으므로. 사용자가
|
||||
// 폼을 편집하기 시작했으면(dirty) 조용히 skip 해서 UX 깨지지 않게.
|
||||
getTemplateInfo(templateId)
|
||||
.then((tpl) => {
|
||||
// 진단용 로그 — template.views 구조 / screenResolutions 존재 여부 확인
|
||||
/* eslint-disable no-console */
|
||||
const v = (tpl as any)?.views ?? (tpl as any)?.VIEWS;
|
||||
console.log('[form-popup fetch]', {
|
||||
hasTpl: !!tpl,
|
||||
keys: tpl ? Object.keys(tpl as any) : null,
|
||||
viewsType: typeof v,
|
||||
viewsIsString: typeof v === 'string',
|
||||
viewsPreview:
|
||||
typeof v === 'string' ? String(v).slice(0, 200) : undefined,
|
||||
createSR:
|
||||
typeof v === 'object'
|
||||
? (v as any)?.screenResolutions?.create
|
||||
: undefined,
|
||||
globalSR:
|
||||
typeof v === 'object'
|
||||
? (v as any)?.screenResolution
|
||||
: undefined,
|
||||
hasScreenResolutions:
|
||||
typeof v === 'object'
|
||||
? !!(v as any)?.screenResolutions
|
||||
: undefined,
|
||||
});
|
||||
/* eslint-enable no-console */
|
||||
if (tpl) {
|
||||
setTemplate(tpl as Template);
|
||||
if (Array.isArray((tpl as any).fields)) {
|
||||
setFields((tpl as any).fields as FieldConfig[]);
|
||||
}
|
||||
if (!seededName) setTemplateName((tpl as any).name ?? '');
|
||||
if (!seededTable) {
|
||||
const pt =
|
||||
(tpl as any).primary_table ?? (tpl as any).PRIMARY_TABLE ?? '';
|
||||
setPrimaryTable(pt);
|
||||
}
|
||||
if (!tpl) {
|
||||
if (!seededTemplate) setLoaded(true);
|
||||
return;
|
||||
}
|
||||
// dirty 면 교체하지 않고 skip — 사용자가 이미 폼 편집 중
|
||||
if (formRowDirtyRef.current && seededTemplate) return;
|
||||
setTemplate(tpl as Template);
|
||||
if (Array.isArray((tpl as any).fields)) {
|
||||
setFields((tpl as any).fields as FieldConfig[]);
|
||||
}
|
||||
if (!seededName) setTemplateName((tpl as any).name ?? '');
|
||||
if (!seededTable) {
|
||||
const pt =
|
||||
(tpl as any).primary_table ?? (tpl as any).PRIMARY_TABLE ?? '';
|
||||
setPrimaryTable(pt);
|
||||
}
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err?.message ?? '템플릿 로드 실패');
|
||||
setLoaded(true);
|
||||
// seed 로 이미 렌더됐으면 에러는 무시. seed 없었으면 에러 표시.
|
||||
if (!seededTemplate) {
|
||||
setError(err?.message ?? '템플릿 로드 실패');
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
}, [templateId, key]);
|
||||
|
||||
@@ -130,6 +152,7 @@ function FormPopupContent() {
|
||||
}, [fields]);
|
||||
|
||||
const handleFormRowChange = useCallback((patch: Record<string, any>) => {
|
||||
formRowDirtyRef.current = true;
|
||||
setFormRow((prev) => ({ ...(prev ?? {}), ...patch }));
|
||||
}, []);
|
||||
|
||||
@@ -211,9 +234,40 @@ function FormPopupContent() {
|
||||
],
|
||||
);
|
||||
|
||||
// 부모가 localStorage 로 template 을 시드해주면 같은 useEffect 턴에 loaded=true
|
||||
// 되므로 로딩 플래시가 사실상 보이지 않는다. 폴백 API 호출 중에만 잠깐 빈 화면.
|
||||
if (!loaded) return null;
|
||||
// 로딩 상태에서도 dev 모드 JIT 컴파일 시간 동안 빈 화면 대신 헤더 + 스켈레톤
|
||||
// 을 즉시 표시해서 사용자 체감 반응성을 개선. (dev 모드는 첫 진입에 번들
|
||||
// 컴파일로 수초 걸릴 수 있음)
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
<div className="border-border flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{templateName
|
||||
? `${templateName} ${mode === 'create' ? '등록' : '수정'}`
|
||||
: '불러오는 중...'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="hover:bg-muted flex h-7 w-7 items-center justify-center rounded"
|
||||
title="닫기 (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid animate-pulse grid-cols-2 gap-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="h-3 w-16 rounded bg-muted" />
|
||||
<div className="h-9 w-full rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error)
|
||||
return (
|
||||
<div className="p-6 text-sm text-destructive">⚠ {error}</div>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* BlockRenderer — 하나의 BlockV2 를 실제 React 컴포넌트로 렌더한다.
|
||||
* ComponentRegistry 에서 componentId 로 정의를 찾아 위임.
|
||||
*
|
||||
* TemplateRenderer (line grid) 와 PopupTemplateRenderer (absolute) 양쪽에서
|
||||
* 공유되는 최소 단위. 별도 파일로 분리한 이유는 팝업 번들 경량화 —
|
||||
* PopupTemplateRenderer 가 TemplateRenderer 전체(line layout 알고리즘 800+줄)
|
||||
* 를 딸려 로드하지 않도록.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { BlockV2, CanvasV2 } from '@/types/invyone-component';
|
||||
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
|
||||
// side-effect: 컴포넌트 레지스트리 등록
|
||||
import '@/lib/registry/components';
|
||||
import type { TemplateRenderContext, ViewKey } from './TemplateRenderer';
|
||||
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
context,
|
||||
view,
|
||||
canvas,
|
||||
runtimeSize,
|
||||
}: {
|
||||
block: BlockV2;
|
||||
context: TemplateRenderContext;
|
||||
view: ViewKey;
|
||||
/**
|
||||
* 선택: 제공되면 block 의 %좌표를 px 로 환산해 컴포넌트에 전달한다.
|
||||
* line grid 의 실제 runtime cell 크기를 component.size 로 전달한다.
|
||||
*/
|
||||
canvas?: CanvasV2;
|
||||
runtimeSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}) {
|
||||
const resolvedTableName =
|
||||
block.config?.selectedTable ||
|
||||
block.config?.tableName ||
|
||||
context.primaryTable;
|
||||
const resolvedColumnName =
|
||||
block.config?.columnName ||
|
||||
block.config?.column_name ||
|
||||
block.config?.fieldKey ||
|
||||
block.config?.bindField ||
|
||||
block.config?.column;
|
||||
const resolvedValue =
|
||||
resolvedColumnName != null
|
||||
? context.formRow?.[resolvedColumnName]
|
||||
: undefined;
|
||||
const runtimeConfig =
|
||||
resolvedColumnName != null
|
||||
? { ...block.config, defaultValue: resolvedValue }
|
||||
: block.config;
|
||||
const handleFormValueChange = (
|
||||
fieldNameOrPatch: string | Record<string, any>,
|
||||
value?: any,
|
||||
) => {
|
||||
if (typeof fieldNameOrPatch === 'string') {
|
||||
context.onFormRowChange?.({ [fieldNameOrPatch]: value });
|
||||
return;
|
||||
}
|
||||
context.onFormRowChange?.(fieldNameOrPatch);
|
||||
};
|
||||
|
||||
const def = ComponentRegistry.getComponent(block.componentId);
|
||||
if (!def?.component) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] font-bold">
|
||||
{block.componentId || '(empty)'}
|
||||
</div>
|
||||
<div className="opacity-70">미등록</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Cmp = def.component as React.ComponentType<any>;
|
||||
|
||||
const bw = canvas?.baseWidth ?? 0;
|
||||
const bh = canvas?.baseHeight ?? 0;
|
||||
const position = {
|
||||
x: bw > 0 ? block.xPct * bw : 0,
|
||||
y: bh > 0 ? block.yPct * bh : 0,
|
||||
z: 1,
|
||||
};
|
||||
const size = {
|
||||
width: bw > 0 ? block.wPct * bw : 0,
|
||||
height: bh > 0 ? block.hPct * bh : 0,
|
||||
};
|
||||
const isButtonLike =
|
||||
block.componentId === 'button' ||
|
||||
block.componentId === 'button-bar' ||
|
||||
block.componentId === 'pagination';
|
||||
const effectiveSize =
|
||||
isButtonLike && runtimeSize
|
||||
? {
|
||||
width:
|
||||
runtimeSize.width > 0
|
||||
? Math.min(size.width, Math.max(0, runtimeSize.width - 2))
|
||||
: size.width,
|
||||
height:
|
||||
runtimeSize.height > 0
|
||||
? Math.min(size.height, Math.max(0, runtimeSize.height - 2))
|
||||
: size.height,
|
||||
}
|
||||
: size;
|
||||
|
||||
return (
|
||||
<Cmp
|
||||
component={{
|
||||
id: block.id,
|
||||
componentType: block.componentId,
|
||||
tableName: resolvedTableName,
|
||||
columnName: resolvedColumnName,
|
||||
column_name: resolvedColumnName,
|
||||
value: resolvedValue,
|
||||
position,
|
||||
size: effectiveSize,
|
||||
componentConfig: runtimeConfig,
|
||||
component_config: runtimeConfig,
|
||||
style: {},
|
||||
}}
|
||||
componentConfig={runtimeConfig}
|
||||
config={runtimeConfig}
|
||||
tableName={resolvedTableName}
|
||||
columnName={resolvedColumnName}
|
||||
column_name={resolvedColumnName}
|
||||
value={resolvedValue}
|
||||
isDesignMode={false}
|
||||
isPreview={true}
|
||||
formData={context.formRow}
|
||||
form_data={context.formRow}
|
||||
onFormDataChange={(fieldName: string, value: any) =>
|
||||
handleFormValueChange(fieldName, value)
|
||||
}
|
||||
onChange={(value: any) =>
|
||||
resolvedColumnName
|
||||
? handleFormValueChange(resolvedColumnName, value)
|
||||
: undefined
|
||||
}
|
||||
originalData={context.formRow}
|
||||
_originalData={context.formRow}
|
||||
onSearch={context.onSearch}
|
||||
searchParams={context.searchParams}
|
||||
onRowSelect={context.onRowSelect}
|
||||
onAdd={context.onAdd}
|
||||
onEdit={context.onEdit}
|
||||
onDelete={context.onDelete}
|
||||
onFormSubmit={context.onFormSubmit}
|
||||
onFormCancel={context.onFormCancel}
|
||||
selectedRow={context.selectedRow}
|
||||
view={view}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -183,21 +183,21 @@ export function DashboardCard({
|
||||
}
|
||||
const key = newPopupKey();
|
||||
try {
|
||||
// initialRow / primaryTable / templateName 만 넘긴다.
|
||||
// template 객체 자체는 넘기지 않는다 — 부모 DashboardCard 가 들고 있는
|
||||
// template 은 경량/정규화 전일 수 있어 views.screenResolutions 가
|
||||
// 누락된 상태로 팝업에 흘러들어가는 stale 버그를 유발한다.
|
||||
// 팝업은 templateId 로 자기 자신이 재fetch 한다.
|
||||
// Optimistic seed — 부모가 들고 있는 template 을 넘겨서 팝업이 로딩
|
||||
// 플래시 없이 즉시 렌더하게 한다. 팝업은 이걸로 즉시 렌더 후
|
||||
// 백그라운드에서 getTemplateInfo 로 재검증해서 stale 이면 교체.
|
||||
localStorage.setItem(
|
||||
`form-popup:${key}`,
|
||||
JSON.stringify({
|
||||
initialRow,
|
||||
primaryTable,
|
||||
templateName,
|
||||
template,
|
||||
fetchedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* storage 실패해도 팝업은 열어줌 — 팝업 쪽에서 빈 데이터로 시작 */
|
||||
/* storage 실패해도 팝업은 열어줌 — 팝업 쪽에서 API 로 fetch */
|
||||
}
|
||||
popupKeysRef.current.add(key);
|
||||
const url = `/form-popup?templateId=${encodeURIComponent(
|
||||
|
||||
@@ -31,13 +31,11 @@ import type {
|
||||
Template,
|
||||
} from '@/types/invyone-component';
|
||||
import { ensureV2Views } from '@/lib/utils/templateMigrate';
|
||||
import {
|
||||
BlockRenderer,
|
||||
type TemplateRenderContext,
|
||||
type ViewKey,
|
||||
import { BlockRenderer } from './BlockRenderer';
|
||||
import type {
|
||||
TemplateRenderContext,
|
||||
ViewKey,
|
||||
} from './TemplateRenderer';
|
||||
// side-effect: 컴포넌트 레지스트리 등록 (BlockRenderer 가 사용)
|
||||
import '@/lib/registry/components';
|
||||
|
||||
interface PopupTemplateRendererProps {
|
||||
template: Template | any;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Template,
|
||||
ViewV2,
|
||||
} from '@/types/invyone-component';
|
||||
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
|
||||
import {
|
||||
allocateRowHeightsPx,
|
||||
classifyRows,
|
||||
@@ -16,8 +15,8 @@ import {
|
||||
toTrackTemplate,
|
||||
} from '@/lib/layout/lineLayout';
|
||||
import { ensureV2Views } from '@/lib/utils/templateMigrate';
|
||||
// side-effect: 컴포넌트 레지스트리 등록
|
||||
import '@/lib/registry/components';
|
||||
import { BlockRenderer } from './BlockRenderer';
|
||||
export { BlockRenderer } from './BlockRenderer';
|
||||
|
||||
/**
|
||||
* TemplateRenderer — INVYONE 템플릿 렌더러
|
||||
@@ -616,144 +615,3 @@ const LINE_CSS = `
|
||||
}
|
||||
`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BlockRenderer — ComponentRegistry 위임.
|
||||
// PopupTemplateRenderer 등 외부 파일에서도 재사용 가능하도록 export.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
context,
|
||||
view,
|
||||
canvas,
|
||||
runtimeSize,
|
||||
}: {
|
||||
block: BlockV2;
|
||||
context: TemplateRenderContext;
|
||||
view: ViewKey;
|
||||
/**
|
||||
* 선택: 제공되면 block 의 %좌표를 px 로 환산해 컴포넌트에 전달한다.
|
||||
* line grid 의 실제 runtime cell 크기를 component.size 로 전달한다.
|
||||
*/
|
||||
canvas?: CanvasV2;
|
||||
runtimeSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}) {
|
||||
const resolvedTableName =
|
||||
block.config?.selectedTable ||
|
||||
block.config?.tableName ||
|
||||
context.primaryTable;
|
||||
const resolvedColumnName =
|
||||
block.config?.columnName ||
|
||||
block.config?.column_name ||
|
||||
block.config?.fieldKey ||
|
||||
block.config?.bindField ||
|
||||
block.config?.column;
|
||||
const resolvedValue =
|
||||
resolvedColumnName != null
|
||||
? context.formRow?.[resolvedColumnName]
|
||||
: undefined;
|
||||
const runtimeConfig =
|
||||
resolvedColumnName != null
|
||||
? { ...block.config, defaultValue: resolvedValue }
|
||||
: block.config;
|
||||
const handleFormValueChange = (fieldNameOrPatch: string | Record<string, any>, value?: any) => {
|
||||
if (typeof fieldNameOrPatch === 'string') {
|
||||
context.onFormRowChange?.({ [fieldNameOrPatch]: value });
|
||||
return;
|
||||
}
|
||||
context.onFormRowChange?.(fieldNameOrPatch);
|
||||
};
|
||||
|
||||
const def = ComponentRegistry.getComponent(block.componentId);
|
||||
if (!def?.component) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] font-bold">
|
||||
{block.componentId || '(empty)'}
|
||||
</div>
|
||||
<div className="opacity-70">미등록</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Cmp = def.component as React.ComponentType<any>;
|
||||
|
||||
const bw = canvas?.baseWidth ?? 0;
|
||||
const bh = canvas?.baseHeight ?? 0;
|
||||
const position = {
|
||||
x: bw > 0 ? block.xPct * bw : 0,
|
||||
y: bh > 0 ? block.yPct * bh : 0,
|
||||
z: 1,
|
||||
};
|
||||
const size = {
|
||||
width: bw > 0 ? block.wPct * bw : 0,
|
||||
height: bh > 0 ? block.hPct * bh : 0,
|
||||
};
|
||||
const isButtonLike =
|
||||
block.componentId === 'button' ||
|
||||
block.componentId === 'button-bar' ||
|
||||
block.componentId === 'pagination';
|
||||
const effectiveSize =
|
||||
isButtonLike && runtimeSize
|
||||
? {
|
||||
width:
|
||||
runtimeSize.width > 0
|
||||
? Math.min(size.width, Math.max(0, runtimeSize.width - 2))
|
||||
: size.width,
|
||||
height:
|
||||
runtimeSize.height > 0
|
||||
? Math.min(size.height, Math.max(0, runtimeSize.height - 2))
|
||||
: size.height,
|
||||
}
|
||||
: size;
|
||||
|
||||
return (
|
||||
<Cmp
|
||||
component={{
|
||||
id: block.id,
|
||||
componentType: block.componentId,
|
||||
tableName: resolvedTableName,
|
||||
columnName: resolvedColumnName,
|
||||
column_name: resolvedColumnName,
|
||||
value: resolvedValue,
|
||||
position,
|
||||
size: effectiveSize,
|
||||
componentConfig: runtimeConfig,
|
||||
component_config: runtimeConfig,
|
||||
style: {},
|
||||
}}
|
||||
componentConfig={runtimeConfig}
|
||||
config={runtimeConfig}
|
||||
tableName={resolvedTableName}
|
||||
columnName={resolvedColumnName}
|
||||
column_name={resolvedColumnName}
|
||||
value={resolvedValue}
|
||||
isDesignMode={false}
|
||||
isPreview={true}
|
||||
formData={context.formRow}
|
||||
form_data={context.formRow}
|
||||
onFormDataChange={(fieldName: string, value: any) =>
|
||||
handleFormValueChange(fieldName, value)
|
||||
}
|
||||
onChange={(value: any) =>
|
||||
resolvedColumnName ? handleFormValueChange(resolvedColumnName, value) : undefined
|
||||
}
|
||||
originalData={context.formRow}
|
||||
_originalData={context.formRow}
|
||||
onSearch={context.onSearch}
|
||||
searchParams={context.searchParams}
|
||||
onRowSelect={context.onRowSelect}
|
||||
onAdd={context.onAdd}
|
||||
onEdit={context.onEdit}
|
||||
onDelete={context.onDelete}
|
||||
onFormSubmit={context.onFormSubmit}
|
||||
onFormCancel={context.onFormCancel}
|
||||
selectedRow={context.selectedRow}
|
||||
view={view}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,19 +49,40 @@ export interface TemplateSavePayload {
|
||||
*/
|
||||
export async function saveTemplate(p: TemplateSavePayload): Promise<void> {
|
||||
const v2Layout = convertLegacyToV2(p.layout as any);
|
||||
|
||||
// screenResolutions 병합을 key-level 폴백으로 강화.
|
||||
// 기존: `p.viewScreenResolutions ?? 전체폴백` → {} 나 일부 키 누락 시 정보 유실
|
||||
// 개선: 항상 3뷰 모두 값이 보장되게 key 별로 폴백 처리
|
||||
const fallbackSr = p.layout.screenResolution;
|
||||
const vsrIn = (p.viewScreenResolutions ?? {}) as Record<string, any>;
|
||||
const screenResolutions = {
|
||||
list: vsrIn.list ?? fallbackSr,
|
||||
create: vsrIn.create ?? fallbackSr,
|
||||
edit: vsrIn.edit ?? fallbackSr,
|
||||
};
|
||||
|
||||
const viewsJson = {
|
||||
list: (v2Layout as any)?.components ?? [],
|
||||
create: p.v2Views?.create ?? [],
|
||||
edit: p.v2Views?.edit ?? [],
|
||||
gridSettings: p.layout.gridSettings,
|
||||
screenResolution: p.layout.screenResolution,
|
||||
screenResolutions: p.viewScreenResolutions ?? {
|
||||
list: p.layout.screenResolution,
|
||||
create: p.layout.screenResolution,
|
||||
edit: p.layout.screenResolution,
|
||||
},
|
||||
screenResolution: fallbackSr,
|
||||
screenResolutions,
|
||||
mainTableName: p.primaryTable,
|
||||
};
|
||||
|
||||
// 진단 로그 — 저장 payload 확인용. 문제 재현 시 여기서 확인 가능.
|
||||
/* eslint-disable no-console */
|
||||
console.log("[saveTemplate] payload", {
|
||||
templateId: p.templateId,
|
||||
viewsKeys: Object.keys(viewsJson),
|
||||
screenResolutions,
|
||||
listCount: viewsJson.list.length,
|
||||
createCount: viewsJson.create.length,
|
||||
editCount: viewsJson.edit.length,
|
||||
});
|
||||
/* eslint-enable no-console */
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: p.name,
|
||||
category: p.category ?? "custom",
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# 회사별 DB 자동 프로비저닝 & 서브도메인 라우팅 — 실행 계획
|
||||
|
||||
작성일: 2026-04-24
|
||||
작성자: gbpark (강빈)
|
||||
원본 설계: `~/Downloads/COMPANY_DB_PROVISIONING_PLAN.md` (chpark, 2026-04-23)
|
||||
리뷰 반영: Claude + Codex 크로스 리뷰 → 강빈 확인 후 최종 결정
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- 원본 설계 방향 **OK**. 바로 Phase 1 착수.
|
||||
- 초기 설정에 **Hikari `minIdle=0`** + **프로비저닝 상태 머신** 2가지만 반드시 포함.
|
||||
- 커넥션 폭증(회사 20~30개 이상)은 그 시점에 `max_connections` 한 번 올리는 걸로 대응. 지금 고민 불필요.
|
||||
- 현재 운영 중 회사 없음 → 기존 데이터 이관 이슈 **제로**. 클린 스타트.
|
||||
|
||||
---
|
||||
|
||||
## 1. 전제 재확인 (리뷰 후 결정)
|
||||
|
||||
### 1.1 "회사 DB 생성 후 DDL 변경 없음" 전제
|
||||
**해석 재정의 (gbpark 확인):**
|
||||
- "최고 관리자 기준 **베이스 템플릿**은 DDL 변경 없음" 의 의미.
|
||||
- 각 회사 DB 내부에서 사용자가 `DdlService`를 통해 컬럼/테이블 추가하는 건 **그 회사 DB 안에서만** 발생.
|
||||
- 다른 회사 DB에 전파될 필요 없음 → 오히려 DB-per-tenant의 구조적 장점.
|
||||
- 따라서 "N개 DB에 DDL 일괄 전파" 문제는 구조적으로 제거됨 ✓
|
||||
|
||||
### 1.2 기존 운영 회사 데이터 이관
|
||||
- 현재 운영 중 회사 **없음**. 클린 스타트.
|
||||
- 원본 문서에는 이 시나리오가 빠져있으나 해당 없으므로 무시.
|
||||
|
||||
### 1.3 메타 DB SPOF
|
||||
- 지금 단일 DB 구조에서도 어차피 SPOF. 동일 조건.
|
||||
- 추후(회사 수 증가 시) 이중화 검토 대상으로만 남겨둠. 지금은 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 2. 초기 설계에 반드시 포함할 것 (★ 빠뜨리면 나중에 고통)
|
||||
|
||||
### 2.1 Hikari 커넥션 풀 정책
|
||||
|
||||
**이유:** 회사마다 DataSource가 복제되므로, 기본 `minIdle=2` 그대로 두면 회사 50개일 때 상시 100개 커넥션 점유 → Postgres 기본 `max_connections=100` 한계 도달.
|
||||
|
||||
**기존 (`backend-spring/src/main/resources/application.yml` 17~26줄):**
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 2 # ← 문제
|
||||
idle-timeout: 600000 # 10분
|
||||
```
|
||||
|
||||
**회사 DB용 풀 생성 시 (TenantRoutingDataSource 내부):**
|
||||
```yaml
|
||||
maximum-pool-size: 5 # 회사당 최대 5개면 충분
|
||||
minimum-idle: 0 # ★ 평소 0개 (안 쓰는 회사는 통로 0)
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 60000 # 1분 놀면 즉시 정리
|
||||
max-lifetime: 1800000
|
||||
```
|
||||
|
||||
→ 메타 DB용 설정(`application.yml`)은 기존 그대로 유지. 회사 DB 풀 만드는 코드에서만 이 정책 박을 것.
|
||||
|
||||
### 2.2 프로비저닝 상태 머신
|
||||
|
||||
**이유:** `CREATE DATABASE` → 스키마 복제 → 관리자 계정 생성 → 메타 기록, 이 4단계 중 어디서 터져도 고아 DB/미등록 상태가 남음. 보상 로직 없으면 손으로 청소해야 함.
|
||||
|
||||
**`COMPANY_MNG.DB_STATUS` 컬럼 값:**
|
||||
```
|
||||
provisioning ← CREATE DATABASE 시작 시 기록
|
||||
schema_copied ← pg_dump 복제 성공 후
|
||||
admin_created ← 관리자 계정 INSERT 성공 후
|
||||
active ← 메타 DB 기록 완료, 로그인 허용
|
||||
failed ← 중간 실패 시. 관리 화면에서 재시도 or 청소
|
||||
suspended ← 회사 삭제/중지 (기존 설계 그대로)
|
||||
```
|
||||
|
||||
**원칙:**
|
||||
- 각 단계 성공마다 메타 DB의 `DB_STATUS` 업데이트 (각 단계가 독립 트랜잭션).
|
||||
- `active` 상태 아닌 DB는 로그인 필터에서 403 반환.
|
||||
- `failed` 상태는 SUPER_ADMIN이 "재프로비저닝" 또는 "DROP DATABASE 후 레코드 삭제" 선택 가능.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase별 실행 체크리스트 (원본 Phase 1~6 기반, 필수 항목 추가)
|
||||
|
||||
### Phase 1 — 메타 스키마 & 도메인 파싱
|
||||
- [ ] `COMPANY_MNG` 컬럼 추가: `DB_NAME`, `SUBDOMAIN`, `DB_HOST`, `DB_STATUS`
|
||||
- [ ] `SubdomainResolverFilter` (`OncePerRequestFilter`) 구현
|
||||
- [ ] `DbContextHolder` (ThreadLocal) 구현
|
||||
- [ ] 라우팅은 no-op: 모두 메타 DB로 접속 + 서브도메인 인식 로그만 확인
|
||||
- [ ] `invyone.com` / `admin.invyone.com` 은 메타 DB 강제 (예외 경로)
|
||||
|
||||
### Phase 2 — 멀티 DataSource 스위칭
|
||||
- [ ] `TenantRoutingDataSource extends AbstractRoutingDataSource` 구현
|
||||
- [ ] **★ 회사 DataSource 생성 시 Hikari `minIdle=0` 박기** (2.1 참조)
|
||||
- [ ] `resolvedDataSources` 캐시 (Map) + lazy init
|
||||
- [ ] 수동 생성한 테스트 DB 2개로 회사 전환 검증
|
||||
- [ ] JWT `companyCode`/`dbName` 클레임 검증 (필터 결정값 불일치 → 401)
|
||||
|
||||
### Phase 3 — 프로비저닝 서비스
|
||||
- [ ] `/api/admin/provisioning/companies` POST 구현
|
||||
- [ ] **★ `COMPANY_MNG.DB_STATUS` 상태 머신 구현** (2.2 참조)
|
||||
- [ ] 테이블 그룹 상수 정의 + `/api/admin/provisioning/table-groups` GET
|
||||
- [ ] 마법사 UI (3-step): 회사정보 → 테이블 선택 → 확인&생성
|
||||
- [ ] `pg_dump --schema-only` 기반 스키마 복제
|
||||
- [ ] DB 이름 검증 정규식: `^[a-z][a-z0-9_]{2,30}$`
|
||||
- [ ] `CREATE DATABASE` 실패 시 `DROP DATABASE` 보상 로직
|
||||
|
||||
### Phase 4 — 관리자 계정 자동 생성 + 프론트 마법사
|
||||
- [ ] `{prefix}_admin` 자동 생성 (BCrypt)
|
||||
- [ ] 첫 로그인 비밀번호 변경 강제 화면
|
||||
- [ ] 초기 비밀번호는 환경변수 (`INITIAL_ADMIN_PASSWORD`)로 분리. 하드코딩 금지.
|
||||
|
||||
### Phase 5 — SUPER_ADMIN "서브도메인 관리" 메뉴
|
||||
- [ ] 경로: `/admin/sysMng/subdomainList`
|
||||
- [ ] 목록/상태/재프로비저닝 UI
|
||||
- [ ] `failed` 상태 → 재시도 or 청소 버튼
|
||||
- [ ] `DB_STATUS` 변경 이력 감사 로그
|
||||
|
||||
### Phase 6 — 운영 자동화
|
||||
- [ ] 회사 DB별 `pg_basebackup` 스케줄
|
||||
- [ ] 회사별 커넥션 풀 모니터링 대시보드
|
||||
- [ ] (스키마 마이그레이션 Runner는 **불필요** — 1.1 참조)
|
||||
|
||||
---
|
||||
|
||||
## 4. 미결정 → 결정 (원본 9장 업데이트)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| 단일 Postgres 인스턴스에 N개 DB? | **YES.** 초기엔 같은 인스턴스. 회사 20~30개 넘으면 그때 `max_connections` 올림 (기본 100 → 500). 그래도 부족하면 DB 서버 분산 (`DB_HOST` 컬럼이 대비). |
|
||||
| `pg_dump` 실행 위치 | 백엔드 컨테이너에 포함. K8s 이미지에 `postgresql-client` 추가. 사이드카는 오버엔지니어링. |
|
||||
| SUPER_ADMIN 회사 DB 임시 전환 | **Phase 5 이후 검토.** 당장 불필요. |
|
||||
| 회사 삭제 시 처리 | **soft delete** (`DB_STATUS='suspended'`) 기본. 완전 삭제는 SUPER_ADMIN 수동 확인 거친 후에만. |
|
||||
| 초기 비밀번호 | **환경변수** `INITIAL_ADMIN_PASSWORD` (Phase 4에서 처리). 하드코딩 금지. |
|
||||
| 메타 DB 이중화 | **Phase 6 이후** 회사 수 보고 결정. 지금은 동일 SPOF로 운영. |
|
||||
|
||||
---
|
||||
|
||||
## 5. 지금 당장 건드릴 것 (오늘 작업)
|
||||
|
||||
1. `backend-spring/src/main/resources/application.yml` — 메타 DB용 Hikari는 그대로 유지 (변경 없음).
|
||||
2. `COMPANY_MNG` 테이블 ALTER → DDL 작성 (Phase 1).
|
||||
3. `SubdomainResolverFilter` 뼈대 작성.
|
||||
4. `DbContextHolder` 작성.
|
||||
|
||||
---
|
||||
|
||||
## 6. 오늘 건드리지 말 것
|
||||
|
||||
- 회사 DB 실제 프로비저닝 로직 (Phase 3 작업).
|
||||
- 프론트 마법사 UI (Phase 3).
|
||||
- `TenantRoutingDataSource` (Phase 2).
|
||||
|
||||
**Phase 1 은 라우팅 no-op 상태에서 서브도메인 인식 로그만 보는 단계.** 건드리는 범위 최소화해서 회귀 없게.
|
||||
|
||||
---
|
||||
|
||||
## 7. 참고 — 기술 스니펫
|
||||
|
||||
### 7.1 `COMPANY_MNG` ALTER DDL
|
||||
```sql
|
||||
ALTER TABLE COMPANY_MNG
|
||||
ADD COLUMN DB_NAME VARCHAR(64),
|
||||
ADD COLUMN SUBDOMAIN VARCHAR(64) UNIQUE,
|
||||
ADD COLUMN DB_HOST VARCHAR(128),
|
||||
ADD COLUMN DB_STATUS VARCHAR(20) DEFAULT 'active';
|
||||
```
|
||||
|
||||
### 7.2 `SubdomainResolverFilter` 뼈대
|
||||
```java
|
||||
@Component
|
||||
public class SubdomainResolverFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
String host = req.getHeader("Host"); // qnc.invyone.com:port
|
||||
String subdomain = extractSubdomain(host); // "qnc"
|
||||
try {
|
||||
if (subdomain == null || "admin".equals(subdomain) || isBaseDomain(host)) {
|
||||
DbContextHolder.setMeta();
|
||||
} else {
|
||||
String dbName = companyResolver.resolveDbName(subdomain); // 메타 DB 조회(캐시)
|
||||
DbContextHolder.set(dbName);
|
||||
}
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
DbContextHolder.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 `DbContextHolder`
|
||||
```java
|
||||
public final class DbContextHolder {
|
||||
private static final String META = "__META__";
|
||||
private static final ThreadLocal<String> CTX = new ThreadLocal<>();
|
||||
|
||||
public static void set(String dbName) { CTX.set(dbName); }
|
||||
public static void setMeta() { CTX.set(META); }
|
||||
public static String get() { return CTX.get(); }
|
||||
public static boolean isMeta() { return META.equals(CTX.get()); }
|
||||
public static void clear() { CTX.remove(); }
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 회사 DB용 Hikari 빌더 (Phase 2에서 사용)
|
||||
```java
|
||||
HikariConfig cfg = new HikariConfig();
|
||||
cfg.setJdbcUrl("jdbc:postgresql://" + dbHost + ":5432/" + dbName);
|
||||
cfg.setUsername(...);
|
||||
cfg.setPassword(...);
|
||||
cfg.setMaximumPoolSize(5);
|
||||
cfg.setMinimumIdle(0); // ★
|
||||
cfg.setIdleTimeout(60_000); // 1분
|
||||
cfg.setConnectionTimeout(30_000);
|
||||
cfg.setMaxLifetime(1_800_000);
|
||||
return new HikariDataSource(cfg);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 커넥션 터지는 시점 빠른 참조
|
||||
|
||||
| 회사 수 | minIdle=0 (권장) | minIdle=2 (기본) |
|
||||
|---|---|---|
|
||||
| 10 | 활성만 ~10개 | **20개 상시** |
|
||||
| 50 | 활성만 ~30개 | **100개 상시 = 한계** |
|
||||
| 100 | 활성만 ~50개 | **200개 = 이미 터짐** |
|
||||
| 500 | 활성만 ~150개 | — (`max_connections` 증설 필수) |
|
||||
|
||||
→ `minIdle=0` 박으면 회사 100개까지 기본 설정(`max_connections=100`)으로 감당 가능.
|
||||
|
||||
---
|
||||
|
||||
## 9. 다음 세션 진입 시
|
||||
|
||||
이 문서가 진실의 원천. 원본 `~/Downloads/COMPANY_DB_PROVISIONING_PLAN.md` 는 리뷰 전 초안이므로 이 문서가 우선.
|
||||
Reference in New Issue
Block a user