워크플로우 restapi도 연결가능하고여러개 가능하게 구현시켜놓음

This commit is contained in:
leeheejin 2025-12-02 14:24:43 +09:00
parent 30e6595bf3
commit 9078873240
11 changed files with 989 additions and 50 deletions

View File

@ -66,11 +66,12 @@ export class FlowController {
return;
}
// REST API인 경우 테이블 존재 확인 스킵
const isRestApi = dbSourceType === "restapi";
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외)
if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) {
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
const tableExists =
await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) {
@ -92,6 +93,7 @@ export class FlowController {
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
},
userId,
userCompanyCode

View File

@ -1091,4 +1091,150 @@ export class ExternalRestApiConnectionService {
throw new Error("올바르지 않은 인증 타입입니다.");
}
}
/**
* REST API
* REST API의
*/
static async fetchMultipleData(
configs: Array<{
connectionId: number;
endpoint: string;
jsonPath: string;
alias: string;
}>,
userCompanyCode?: string
): Promise<ApiResponse<{
rows: any[];
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
total: number;
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
}>> {
try {
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
// 각 API에서 데이터 조회
const results = await Promise.all(
configs.map(async (config) => {
try {
const result = await this.fetchData(
config.connectionId,
config.endpoint,
config.jsonPath,
userCompanyCode
);
if (result.success && result.data) {
return {
success: true,
connectionId: config.connectionId,
connectionName: result.data.connectionInfo.connectionName,
alias: config.alias,
rows: result.data.rows,
columns: result.data.columns,
};
} else {
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: result.message,
};
}
} catch (error) {
logger.error(`API ${config.connectionId} 조회 오류:`, error);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
})
);
// 성공한 결과만 필터링
const successfulResults = results.filter(r => r.success);
if (successfulResults.length === 0) {
return {
success: false,
message: "모든 REST API 조회에 실패했습니다.",
error: {
code: "ALL_APIS_FAILED",
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
},
};
}
// 컬럼 병합 (별칭 적용)
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
for (const result of successfulResults) {
for (const col of result.columns) {
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
mergedColumns.push({
columnName: prefixedColumnName,
columnLabel: `${col.columnLabel} (${result.connectionName})`,
dataType: col.dataType,
sourceApi: result.connectionName,
});
}
}
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
const mergedRows: any[] = [];
for (let i = 0; i < maxRows; i++) {
const mergedRow: any = {};
for (const result of successfulResults) {
const row = result.rows[i] || {};
for (const [key, value] of Object.entries(row)) {
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
mergedRow[prefixedKey] = value;
}
}
mergedRows.push(mergedRow);
}
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
return {
success: true,
data: {
rows: mergedRows,
columns: mergedColumns,
total: mergedRows.length,
sources: successfulResults.map(r => ({
connectionId: r.connectionId,
connectionName: r.connectionName,
rowCount: r.rows.length,
})),
},
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("다중 REST API 데이터 조회 오류:", error);
return {
success: false,
message: "다중 REST API 데이터 조회에 실패했습니다.",
error: {
code: "MULTI_FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
}

View File

@ -30,6 +30,7 @@ export class FlowDefinitionService {
restApiConnectionId: request.restApiConnectionId,
restApiEndpoint: request.restApiEndpoint,
restApiJsonPath: request.restApiJsonPath,
restApiConnections: request.restApiConnections,
companyCode,
userId,
});
@ -38,9 +39,9 @@ export class FlowDefinitionService {
INSERT INTO flow_definition (
name, description, table_name, db_source_type, db_connection_id,
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
company_code, created_by
rest_api_connections, company_code, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`;
@ -52,7 +53,8 @@ export class FlowDefinitionService {
request.dbConnectionId || null,
request.restApiConnectionId || null,
request.restApiEndpoint || null,
request.restApiJsonPath || "data",
request.restApiJsonPath || "response",
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
companyCode,
userId,
];
@ -209,6 +211,19 @@ export class FlowDefinitionService {
* DB FlowDefinition
*/
private mapToFlowDefinition(row: any): FlowDefinition {
// rest_api_connections 파싱 (JSONB → 배열)
let restApiConnections = undefined;
if (row.rest_api_connections) {
try {
restApiConnections = typeof row.rest_api_connections === 'string'
? JSON.parse(row.rest_api_connections)
: row.rest_api_connections;
} catch (e) {
console.warn("Failed to parse rest_api_connections:", e);
restApiConnections = [];
}
}
return {
id: row.id,
name: row.name,
@ -216,10 +231,12 @@ export class FlowDefinitionService {
tableName: row.table_name,
dbSourceType: row.db_source_type || "internal",
dbConnectionId: row.db_connection_id,
// REST API 관련 필드
// REST API 관련 필드 (단일)
restApiConnectionId: row.rest_api_connection_id,
restApiEndpoint: row.rest_api_endpoint,
restApiJsonPath: row.rest_api_json_path,
// 다중 REST API 관련 필드
restApiConnections: restApiConnections,
companyCode: row.company_code || "*",
isActive: row.is_active,
createdBy: row.created_by,

View File

@ -2,18 +2,38 @@
*
*/
// 다중 REST API 연결 설정
export interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string; // 컬럼 접두어 (예: "api1_")
}
// 다중 외부 DB 연결 설정
export interface ExternalDbConnectionConfig {
connectionId: number;
connectionName: string;
dbType: string;
tableName: string;
alias: string; // 컬럼 접두어 (예: "db1_")
}
// 플로우 정의
export interface FlowDefinition {
id: number;
name: string;
description?: string;
tableName: string;
dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
// REST API 관련 필드
// REST API 관련 필드 (단일)
restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우)
restApiEndpoint?: string; // REST API 엔드포인트
restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data)
// 다중 REST API 관련 필드
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
companyCode: string; // 회사 코드 (* = 공통)
isActive: boolean;
createdBy?: string;
@ -26,12 +46,14 @@ export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID
// REST API 관련 필드
// REST API 관련 필드 (단일)
restApiConnectionId?: number; // REST API 연결 ID
restApiEndpoint?: string; // REST API 엔드포인트
restApiJsonPath?: string; // JSON 응답에서 데이터 경로
// 다중 REST API 관련 필드
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
}

View File

@ -319,6 +319,10 @@ export default function FlowEditorPage() {
flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달
flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달
flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달
flowRestApiConnectionId={flowDefinition?.restApiConnectionId} // REST API 연결 ID 전달
flowRestApiEndpoint={flowDefinition?.restApiEndpoint} // REST API 엔드포인트 전달
flowRestApiJsonPath={flowDefinition?.restApiJsonPath} // REST API JSON 경로 전달
flowRestApiConnections={flowDefinition?.restApiConnections} // 다중 REST API 설정 전달
onClose={() => setSelectedStep(null)}
onUpdate={loadFlowData}
/>

View File

@ -64,7 +64,30 @@ export default function FlowManagementPage() {
// REST API 연결 관련 상태
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [restApiEndpoint, setRestApiEndpoint] = useState("");
const [restApiJsonPath, setRestApiJsonPath] = useState("data");
const [restApiJsonPath, setRestApiJsonPath] = useState("response");
// 다중 REST API 선택 상태
interface RestApiConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string; // 컬럼 접두어 (예: "api1_")
}
const [selectedRestApis, setSelectedRestApis] = useState<RestApiConfig[]>([]);
const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드
// 다중 외부 DB 선택 상태
interface ExternalDbConfig {
connectionId: number;
connectionName: string;
dbType: string;
tableName: string;
alias: string; // 컬럼 접두어 (예: "db1_")
}
const [selectedExternalDbs, setSelectedExternalDbs] = useState<ExternalDbConfig[]>([]);
const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드
const [multiDbTableLists, setMultiDbTableLists] = useState<Record<number, string[]>>({}); // 각 DB별 테이블 목록
// 생성 폼 상태
const [formData, setFormData] = useState({
@ -207,25 +230,161 @@ export default function FlowManagementPage() {
}
}, [selectedDbSource]);
// 다중 외부 DB 추가
const addExternalDbConfig = async (connectionId: number) => {
const connection = externalConnections.find(c => c.id === connectionId);
if (!connection) return;
// 이미 추가된 경우 스킵
if (selectedExternalDbs.some(db => db.connectionId === connectionId)) {
toast({
title: "이미 추가됨",
description: "해당 외부 DB가 이미 추가되어 있습니다.",
variant: "destructive",
});
return;
}
// 해당 DB의 테이블 목록 로드
try {
const data = await ExternalDbConnectionAPI.getTables(connectionId);
if (data.success && data.data) {
const tables = Array.isArray(data.data) ? data.data : [];
const tableNames = tables
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
)
.filter(Boolean);
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
}
const newConfig: ExternalDbConfig = {
connectionId,
connectionName: connection.connection_name,
dbType: connection.db_type,
tableName: "",
alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성
};
setSelectedExternalDbs([...selectedExternalDbs, newConfig]);
};
// 다중 외부 DB 삭제
const removeExternalDbConfig = (connectionId: number) => {
setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId));
};
// 다중 외부 DB 설정 업데이트
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
setSelectedExternalDbs(selectedExternalDbs.map(db =>
db.connectionId === connectionId ? { ...db, [field]: value } : db
));
};
// 다중 REST API 추가
const addRestApiConfig = (connectionId: number) => {
const connection = restApiConnections.find(c => c.id === connectionId);
if (!connection) return;
// 이미 추가된 경우 스킵
if (selectedRestApis.some(api => api.connectionId === connectionId)) {
toast({
title: "이미 추가됨",
description: "해당 REST API가 이미 추가되어 있습니다.",
variant: "destructive",
});
return;
}
// 연결 테이블의 기본값 사용
const newConfig: RestApiConfig = {
connectionId,
connectionName: connection.connection_name,
endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트
jsonPath: "response", // 기본값
alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성
};
setSelectedRestApis([...selectedRestApis, newConfig]);
};
// 다중 REST API 삭제
const removeRestApiConfig = (connectionId: number) => {
setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId));
};
// 다중 REST API 설정 업데이트
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
setSelectedRestApis(selectedRestApis.map(api =>
api.connectionId === connectionId ? { ...api, [field]: value } : api
));
};
// 플로우 생성
const handleCreate = async () => {
console.log("🚀 handleCreate called with formData:", formData);
// REST API인 경우 테이블 이름 검증 스킵
const isRestApi = selectedDbSource.startsWith("restapi_");
// REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵
const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi;
const isMultiMode = isMultiRestApi || isMultiExternalDb;
if (!formData.name || (!isRestApi && !formData.tableName)) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi });
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode });
toast({
title: "입력 오류",
description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive",
});
return;
}
// REST API인 경우 엔드포인트 검증
if (isRestApi && !restApiEndpoint) {
// 다중 REST API 모드인 경우 검증
if (isMultiRestApi) {
if (selectedRestApis.length === 0) {
toast({
title: "입력 오류",
description: "최소 하나의 REST API를 추가해주세요.",
variant: "destructive",
});
return;
}
// 각 API의 엔드포인트 검증
const missingEndpoint = selectedRestApis.find(api => !api.endpoint);
if (missingEndpoint) {
toast({
title: "입력 오류",
description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`,
variant: "destructive",
});
return;
}
} else if (isMultiExternalDb) {
// 다중 외부 DB 모드인 경우 검증
if (selectedExternalDbs.length === 0) {
toast({
title: "입력 오류",
description: "최소 하나의 외부 DB를 추가해주세요.",
variant: "destructive",
});
return;
}
// 각 DB의 테이블 선택 검증
const missingTable = selectedExternalDbs.find(db => !db.tableName);
if (missingTable) {
toast({
title: "입력 오류",
description: `${missingTable.connectionName}의 테이블을 선택해주세요.`,
variant: "destructive",
});
return;
}
} else if (isRestApi && !restApiEndpoint) {
// 단일 REST API인 경우 엔드포인트 검증
toast({
title: "입력 오류",
description: "REST API 엔드포인트는 필수입니다.",
@ -236,11 +395,15 @@ export default function FlowManagementPage() {
try {
// 데이터 소스 타입 및 ID 파싱
let dbSourceType: "internal" | "external" | "restapi" = "internal";
let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal";
let dbConnectionId: number | undefined = undefined;
let restApiConnectionId: number | undefined = undefined;
if (selectedDbSource === "internal") {
if (isMultiRestApi) {
dbSourceType = "multi_restapi";
} else if (isMultiExternalDb) {
dbSourceType = "multi_external_db";
} else if (selectedDbSource === "internal") {
dbSourceType = "internal";
} else if (selectedDbSource.startsWith("external_db_")) {
dbSourceType = "external";
@ -257,11 +420,27 @@ export default function FlowManagementPage() {
dbConnectionId,
};
// REST API인 경우 추가 정보
if (dbSourceType === "restapi") {
// 다중 REST API인 경우
if (dbSourceType === "multi_restapi") {
requestData.restApiConnections = selectedRestApis;
// 다중 REST API는 첫 번째 API의 ID를 기본으로 사용
requestData.restApiConnectionId = selectedRestApis[0]?.connectionId;
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
// 가상 테이블명: 모든 연결 ID를 조합
requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`;
} else if (dbSourceType === "multi_external_db") {
// 다중 외부 DB인 경우
requestData.externalDbConnections = selectedExternalDbs;
// 첫 번째 DB의 ID를 기본으로 사용
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
// 가상 테이블명: 모든 연결 ID와 테이블명 조합
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`;
} else if (dbSourceType === "restapi") {
// 단일 REST API인 경우
requestData.restApiConnectionId = restApiConnectionId;
requestData.restApiEndpoint = restApiEndpoint;
requestData.restApiJsonPath = restApiJsonPath || "data";
requestData.restApiJsonPath = restApiJsonPath || "response";
// REST API는 가상 테이블명 사용
requestData.tableName = `_restapi_${restApiConnectionId}`;
}
@ -277,7 +456,11 @@ export default function FlowManagementPage() {
setFormData({ name: "", description: "", tableName: "" });
setSelectedDbSource("internal");
setRestApiEndpoint("");
setRestApiJsonPath("data");
setRestApiJsonPath("response");
setSelectedRestApis([]);
setSelectedExternalDbs([]);
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
loadFlows();
} else {
toast({
@ -485,13 +668,27 @@ export default function FlowManagementPage() {
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={selectedDbSource}
value={isMultiRestApi ? "multi_restapi" : isMultiExternalDb ? "multi_external_db" : selectedDbSource}
onValueChange={(value) => {
setSelectedDbSource(value);
// 소스 변경 시 테이블 선택 및 REST API 설정 초기화
if (value === "multi_restapi") {
setIsMultiRestApi(true);
setIsMultiExternalDb(false);
setSelectedDbSource("internal");
} else if (value === "multi_external_db") {
setIsMultiExternalDb(true);
setIsMultiRestApi(false);
setSelectedDbSource("internal");
} else {
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
setSelectedDbSource(value);
}
// 소스 변경 시 초기화
setFormData({ ...formData, tableName: "" });
setRestApiEndpoint("");
setRestApiJsonPath("data");
setRestApiJsonPath("response");
setSelectedRestApis([]);
setSelectedExternalDbs([]);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
@ -504,7 +701,7 @@ export default function FlowManagementPage() {
{/* 외부 DB 연결 */}
{externalConnections.length > 0 && (
<>
<SelectItem value="__divider_db__" disabled className="text-xs text-muted-foreground">
<SelectItem value="__divider_db__" disabled className="text-muted-foreground text-xs">
-- --
</SelectItem>
{externalConnections.map((conn) => (
@ -518,7 +715,7 @@ export default function FlowManagementPage() {
{/* REST API 연결 */}
{restApiConnections.length > 0 && (
<>
<SelectItem value="__divider_api__" disabled className="text-xs text-muted-foreground">
<SelectItem value="__divider_api__" disabled className="text-muted-foreground text-xs">
-- REST API --
</SelectItem>
{restApiConnections.map((conn) => (
@ -528,6 +725,25 @@ export default function FlowManagementPage() {
))}
</>
)}
{/* 다중 연결 옵션 */}
{(externalConnections.length > 0 || restApiConnections.length > 0) && (
<>
<SelectItem value="__divider_multi__" disabled className="text-muted-foreground text-xs">
-- ( ) --
</SelectItem>
{externalConnections.length > 0 && (
<SelectItem value="multi_external_db">
DB ( )
</SelectItem>
)}
{restApiConnections.length > 0 && (
<SelectItem value="multi_restapi">
REST API ( )
</SelectItem>
)}
</>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
@ -535,8 +751,160 @@ export default function FlowManagementPage() {
</p>
</div>
{/* REST API인 경우 엔드포인트 설정 */}
{selectedDbSource.startsWith("restapi_") ? (
{/* 다중 REST API 선택 UI */}
{isMultiRestApi && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm">REST API </Label>
<Select
value=""
onValueChange={(value) => {
if (value) {
addRestApiConfig(parseInt(value));
}
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="API 추가..." />
</SelectTrigger>
<SelectContent>
{restApiConnections
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id))
.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRestApis.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm">
REST API를
</p>
</div>
) : (
<div className="space-y-2">
{selectedRestApis.map((api) => (
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{api.connectionName}</span>
<span className="text-muted-foreground text-xs">
({api.endpoint || "기본 엔드포인트"})
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeRestApiConfig(api.connectionId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px] sm:text-xs">
REST API들의 .
</p>
</div>
)}
{/* 다중 외부 DB 선택 UI */}
{isMultiExternalDb && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> DB </Label>
<Select
value=""
onValueChange={(value) => {
if (value) {
addExternalDbConfig(parseInt(value));
}
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="DB 추가..." />
</SelectTrigger>
<SelectContent>
{externalConnections
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id))
.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedExternalDbs.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm">
DB를
</p>
</div>
) : (
<div className="space-y-3">
{selectedExternalDbs.map((db) => (
<div key={db.connectionId} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{db.connectionName} ({db.dbType?.toUpperCase()})
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeExternalDbConfig(db.connectionId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Select
value={db.tableName}
onValueChange={(value) => updateExternalDbConfig(db.connectionId, "tableName", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{(multiDbTableLists[db.connectionId] || []).map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Input
value={db.alias}
onChange={(e) => updateExternalDbConfig(db.connectionId, "alias", e.target.value)}
placeholder="db1_"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px] sm:text-xs">
DB들의 . DB별 .
</p>
</div>
)}
{/* 단일 REST API인 경우 엔드포인트 설정 */}
{!isMultiRestApi && selectedDbSource.startsWith("restapi_") && (
<>
<div>
<Label htmlFor="restApiEndpoint" className="text-xs sm:text-sm">
@ -569,8 +937,10 @@ export default function FlowManagementPage() {
</p>
</div>
</>
) : (
/* 테이블 선택 (내부 DB 또는 외부 DB) */
)}
{/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */}
{!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
<div>
<Label htmlFor="tableName" className="text-xs sm:text-sm">
*

View File

@ -14,13 +14,27 @@ import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { FlowConditionGroup, FlowCondition, ConditionOperator } from "@/types/flow";
import { getTableColumns } from "@/lib/api/tableManagement";
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
import { cn } from "@/lib/utils";
// 다중 REST API 연결 설정
interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string;
}
interface FlowConditionBuilderProps {
flowId: number;
tableName?: string; // 조회할 테이블명
dbSourceType?: "internal" | "external"; // DB 소스 타입
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // DB 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID
restApiConnectionId?: number; // REST API 연결 ID (단일)
restApiEndpoint?: string; // REST API 엔드포인트 (단일)
restApiJsonPath?: string; // REST API JSON 경로 (단일)
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
condition?: FlowConditionGroup;
onChange: (condition: FlowConditionGroup | undefined) => void;
}
@ -45,6 +59,10 @@ export function FlowConditionBuilder({
tableName,
dbSourceType = "internal",
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections,
condition,
onChange,
}: FlowConditionBuilderProps) {
@ -65,9 +83,10 @@ export function FlowConditionBuilder({
}
}, [condition]);
// 테이블 컬럼 로드 - 내부/외부 DB 모두 지원
// 테이블 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
useEffect(() => {
if (!tableName) {
// REST API인 경우 tableName이 없어도 진행 가능
if (!tableName && dbSourceType !== "restapi" && dbSourceType !== "multi_restapi") {
setColumns([]);
return;
}
@ -79,8 +98,106 @@ export function FlowConditionBuilder({
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections,
});
// 다중 REST API인 경우
if (dbSourceType === "multi_restapi" && restApiConnections && restApiConnections.length > 0) {
try {
console.log("🌐 [FlowConditionBuilder] 다중 REST API 컬럼 로드 시작:", restApiConnections);
// 각 API에서 컬럼 정보 수집
const allColumns: any[] = [];
for (const config of restApiConnections) {
try {
const effectiveJsonPath = (!config.jsonPath || config.jsonPath === "data") ? "response" : config.jsonPath;
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
config.connectionId,
config.endpoint,
effectiveJsonPath,
);
if (restApiData.columns && restApiData.columns.length > 0) {
// 별칭 적용
const prefixedColumns = restApiData.columns.map((col) => ({
column_name: config.alias ? `${config.alias}${col.columnName}` : col.columnName,
data_type: col.dataType || "varchar",
displayName: `${col.columnLabel || col.columnName} (${config.connectionName})`,
sourceApi: config.connectionName,
}));
allColumns.push(...prefixedColumns);
}
} catch (apiError) {
console.warn(`API ${config.connectionId} 컬럼 로드 실패:`, apiError);
}
}
console.log("✅ [FlowConditionBuilder] 다중 REST API 컬럼 로드 완료:", allColumns.length, "items");
setColumns(allColumns);
} catch (multiApiError) {
console.error("❌ 다중 REST API 컬럼 로드 실패:", multiApiError);
setColumns([]);
}
return;
}
// 단일 REST API인 경우 (dbSourceType이 restapi이거나 tableName이 _restapi_로 시작)
const isRestApi = dbSourceType === "restapi" || tableName?.startsWith("_restapi_");
// tableName에서 REST API 연결 ID 추출 (restApiConnectionId가 없는 경우)
let effectiveRestApiConnectionId = restApiConnectionId;
if (isRestApi && !effectiveRestApiConnectionId && tableName) {
const match = tableName.match(/_restapi_(\d+)/);
if (match) {
effectiveRestApiConnectionId = parseInt(match[1]);
console.log("🔍 tableName에서 REST API 연결 ID 추출:", effectiveRestApiConnectionId);
}
}
if (isRestApi && effectiveRestApiConnectionId) {
try {
// jsonPath가 "data"이거나 없으면 "response"로 변경 (thiratis API 응답 구조에 맞춤)
const effectiveJsonPath = (!restApiJsonPath || restApiJsonPath === "data") ? "response" : restApiJsonPath;
console.log("🌐 [FlowConditionBuilder] REST API 컬럼 로드 시작:", {
connectionId: effectiveRestApiConnectionId,
endpoint: restApiEndpoint,
jsonPath: restApiJsonPath,
effectiveJsonPath,
});
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
effectiveRestApiConnectionId,
restApiEndpoint,
effectiveJsonPath,
);
console.log("✅ [FlowConditionBuilder] REST API columns response:", restApiData);
if (restApiData.columns && restApiData.columns.length > 0) {
const columnList = restApiData.columns.map((col) => ({
column_name: col.columnName,
data_type: col.dataType || "varchar",
displayName: col.columnLabel || col.columnName,
}));
console.log("✅ Setting REST API columns:", columnList.length, "items", columnList);
setColumns(columnList);
} else {
console.warn("❌ No columns in REST API response");
setColumns([]);
}
} catch (restApiError) {
console.error("❌ REST API 컬럼 로드 실패:", restApiError);
setColumns([]);
}
return;
}
// 외부 DB인 경우
if (dbSourceType === "external" && dbConnectionId) {
const token = localStorage.getItem("authToken");
@ -148,7 +265,7 @@ export function FlowConditionBuilder({
};
loadColumns();
}, [tableName, dbSourceType, dbConnectionId]);
}, [tableName, dbSourceType, dbConnectionId, restApiConnectionId, restApiEndpoint, restApiJsonPath]);
// 조건 변경 시 부모에 전달
useEffect(() => {

View File

@ -30,12 +30,25 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
// 다중 REST API 연결 설정
interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string;
}
interface FlowStepPanelProps {
step: FlowStep;
flowId: number;
flowTableName?: string; // 플로우 정의에서 선택한 테이블명
flowDbSourceType?: "internal" | "external"; // 플로우의 DB 소스 타입
flowDbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // 플로우의 DB 소스 타입
flowDbConnectionId?: number; // 플로우의 외부 DB 연결 ID
flowRestApiConnectionId?: number; // 플로우의 REST API 연결 ID (단일)
flowRestApiEndpoint?: string; // REST API 엔드포인트 (단일)
flowRestApiJsonPath?: string; // REST API JSON 경로 (단일)
flowRestApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
onClose: () => void;
onUpdate: () => void;
}
@ -46,6 +59,10 @@ export function FlowStepPanel({
flowTableName,
flowDbSourceType = "internal",
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiEndpoint,
flowRestApiJsonPath,
flowRestApiConnections,
onClose,
onUpdate,
}: FlowStepPanelProps) {
@ -56,6 +73,9 @@ export function FlowStepPanel({
flowTableName,
flowDbSourceType,
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiEndpoint,
flowRestApiJsonPath,
final: step.tableName || flowTableName || "",
});
@ -315,10 +335,11 @@ export function FlowStepPanel({
setFormData(newFormData);
}, [step.id, flowTableName]); // flowTableName도 의존성 추가
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 모두 지원
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
useEffect(() => {
const loadColumns = async () => {
if (!formData.tableName) {
// 다중 REST API인 경우 tableName 없이도 컬럼 로드 가능
if (!formData.tableName && flowDbSourceType !== "multi_restapi") {
setColumns([]);
return;
}
@ -329,8 +350,74 @@ export function FlowStepPanel({
tableName: formData.tableName,
flowDbSourceType,
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiConnections,
});
// 다중 REST API인 경우
if (flowDbSourceType === "multi_restapi" && flowRestApiConnections && flowRestApiConnections.length > 0) {
console.log("🌐 다중 REST API 컬럼 로드 시작");
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const allColumns: any[] = [];
for (const config of flowRestApiConnections) {
try {
const effectiveJsonPath = (!config.jsonPath || config.jsonPath === "data") ? "response" : config.jsonPath;
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
config.connectionId,
config.endpoint,
effectiveJsonPath,
);
if (restApiData.columns && restApiData.columns.length > 0) {
const prefixedColumns = restApiData.columns.map((col) => ({
column_name: config.alias ? `${config.alias}${col.columnName}` : col.columnName,
data_type: col.dataType || "varchar",
displayName: `${col.columnLabel || col.columnName} (${config.connectionName})`,
}));
allColumns.push(...prefixedColumns);
}
} catch (apiError) {
console.warn(`API ${config.connectionId} 컬럼 로드 실패:`, apiError);
}
}
console.log("✅ 다중 REST API 컬럼 로드 완료:", allColumns.length, "items");
setColumns(allColumns);
return;
}
// 단일 REST API인 경우
const isRestApi = flowDbSourceType === "restapi" || formData.tableName?.startsWith("_restapi_");
if (isRestApi && flowRestApiConnectionId) {
console.log("🌐 단일 REST API 컬럼 로드 시작");
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const effectiveJsonPath = (!flowRestApiJsonPath || flowRestApiJsonPath === "data") ? "response" : flowRestApiJsonPath;
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
flowRestApiConnectionId,
flowRestApiEndpoint,
effectiveJsonPath,
);
if (restApiData.columns && restApiData.columns.length > 0) {
const columnList = restApiData.columns.map((col) => ({
column_name: col.columnName,
data_type: col.dataType || "varchar",
displayName: col.columnLabel || col.columnName,
}));
console.log("✅ REST API 컬럼 로드 완료:", columnList.length, "items");
setColumns(columnList);
} else {
setColumns([]);
}
return;
}
// 외부 DB인 경우
if (flowDbSourceType === "external" && flowDbConnectionId) {
const token = localStorage.getItem("authToken");
@ -399,7 +486,7 @@ export function FlowStepPanel({
};
loadColumns();
}, [formData.tableName, flowDbSourceType, flowDbConnectionId]);
}, [formData.tableName, flowDbSourceType, flowDbConnectionId, flowRestApiConnectionId, flowRestApiEndpoint, flowRestApiJsonPath, flowRestApiConnections]);
// formData의 최신 값을 항상 참조하기 위한 ref
const formDataRef = useRef(formData);
@ -661,6 +748,10 @@ export function FlowStepPanel({
tableName={formData.tableName}
dbSourceType={flowDbSourceType}
dbConnectionId={flowDbConnectionId}
restApiConnectionId={flowRestApiConnectionId}
restApiEndpoint={flowRestApiEndpoint}
restApiJsonPath={flowRestApiJsonPath}
restApiConnections={flowRestApiConnections}
condition={formData.conditionJson}
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
/>
@ -852,7 +943,7 @@ export function FlowStepPanel({
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "rest_api"}
>
{opt.label}
</SelectItem>
@ -1044,6 +1135,132 @@ export function FlowStepPanel({
)}
</div>
)}
{/* REST API 연동 설정 */}
{formData.integrationType === "rest_api" && (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>REST API </Label>
<Select
value={formData.integrationConfig?.connectionId?.toString() || ""}
onValueChange={(value) => {
const connectionId = parseInt(value);
setFormData({
...formData,
integrationConfig: {
type: "rest_api",
connectionId,
operation: "update",
endpoint: "",
method: "POST",
bodyTemplate: "{}",
} as any,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="REST API 연결 선택" />
</SelectTrigger>
<SelectContent>
{flowRestApiConnections && flowRestApiConnections.length > 0 ? (
flowRestApiConnections.map((api) => (
<SelectItem key={api.connectionId} value={api.connectionId.toString()}>
{api.connectionName}
</SelectItem>
))
) : flowRestApiConnectionId ? (
<SelectItem value={flowRestApiConnectionId.toString()}>
REST API
</SelectItem>
) : (
<SelectItem value="" disabled>
REST API가
</SelectItem>
)}
</SelectContent>
</Select>
</div>
{formData.integrationConfig?.connectionId && (
<>
<div>
<Label>HTTP </Label>
<Select
value={(formData.integrationConfig as any).method || "POST"}
onValueChange={(value) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
method: value,
} as any,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={(formData.integrationConfig as any).endpoint || ""}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
endpoint: e.target.value,
} as any,
})
}
placeholder="/api/update"
/>
<p className="text-muted-foreground mt-1 text-xs">
API
</p>
</div>
<div>
<Label> (JSON)</Label>
<Textarea
value={(formData.integrationConfig as any).bodyTemplate || "{}"}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
bodyTemplate: e.target.value,
} as any,
})
}
placeholder='{"id": "{{dataId}}", "status": "approved"}'
rows={4}
className="font-mono text-sm"
/>
</div>
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 릿 :
<br /> {`{{dataId}}`} - ID
<br /> {`{{currentUser}}`} -
<br /> {`{{currentTimestamp}}`} -
</p>
</div>
</>
)}
</div>
)}
</CardContent>
</Card>

View File

@ -846,13 +846,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
restApiConnectionId: selectedScreen?.restApiConnectionId,
restApiEndpoint: selectedScreen?.restApiEndpoint,
restApiJsonPath: selectedScreen?.restApiJsonPath,
// 전체 selectedScreen 객체도 출력
fullScreen: selectedScreen,
});
// REST API 데이터 소스인 경우
// tableName이 restapi_로 시작하면 REST API로 간주
// 1. dataSourceType이 "restapi"인 경우
// 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우
// 3. restApiConnectionId가 있는 경우
const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
selectedScreen?.tableName?.startsWith("restapi_") ||
selectedScreen?.tableName?.startsWith("_restapi_");
selectedScreen?.tableName?.startsWith("_restapi_") ||
!!selectedScreen?.restApiConnectionId;
console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi });
if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) {
try {

View File

@ -33,6 +33,28 @@ export interface FlowConditionGroup {
conditions: FlowCondition[];
}
// ============================================
// 다중 REST API 연결 설정
// ============================================
export interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string; // 컬럼 접두어 (예: "api1_")
}
// ============================================
// 다중 외부 DB 연결 설정
// ============================================
export interface ExternalDbConnectionConfig {
connectionId: number;
connectionName: string;
dbType: string;
tableName: string;
alias: string; // 컬럼 접두어 (예: "db1_")
}
// ============================================
// 플로우 정의
// ============================================
@ -41,6 +63,17 @@ export interface FlowDefinition {
name: string;
description?: string;
tableName: string;
// 데이터 소스 관련
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db";
dbConnectionId?: number;
// REST API 관련 (단일)
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
// 다중 REST API 관련
restApiConnections?: RestApiConnectionConfig[];
// 다중 외부 DB 관련
externalDbConnections?: ExternalDbConnectionConfig[];
isActive: boolean;
createdAt: string;
updatedAt: string;
@ -53,12 +86,16 @@ export interface CreateFlowDefinitionRequest {
description?: string;
tableName: string;
// 데이터 소스 관련
dbSourceType?: "internal" | "external" | "restapi";
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db";
dbConnectionId?: number;
// REST API 관련
// REST API 관련 (단일)
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
// 다중 REST API 관련
restApiConnections?: RestApiConnectionConfig[];
// 다중 외부 DB 관련
externalDbConnections?: ExternalDbConnectionConfig[];
}
export interface UpdateFlowDefinitionRequest {

View File

@ -126,7 +126,7 @@ export const OPERATION_OPTIONS = [
export const INTEGRATION_TYPE_OPTIONS = [
{ value: "internal", label: "내부 DB (기본)" },
{ value: "external_db", label: "외부 DB 연동" },
{ value: "rest_api", label: "REST API (추후 지원)" },
{ value: "rest_api", label: "REST API 연동" },
{ value: "webhook", label: "Webhook (추후 지원)" },
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
] as const;