디스코드 웹 훅 테스트 구현

This commit is contained in:
hyeonsu
2025-09-17 11:47:57 +09:00
parent 536a975dc7
commit f85aac65db
8 changed files with 980 additions and 17 deletions

View File

@@ -5,6 +5,9 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ExternalCallAPI } from "@/lib/api/externalCall";
import { toast } from "sonner";
import { Globe } from "lucide-react";
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
@@ -13,12 +16,128 @@ interface ExternalCallSettingsProps {
onSettingsChange: (settings: ExternalCallSettingsType) => void;
}
const handleTestExternalCall = async (settings: ExternalCallSettingsType) => {
let loadingToastId: string | number | undefined;
try {
// 설정을 백엔드 형식으로 변환
const backendSettings: Record<string, unknown> = {
callType: settings.callType,
timeout: 10000, // 10초 타임아웃 설정
};
if (settings.callType === "rest-api") {
backendSettings.apiType = settings.apiType;
switch (settings.apiType) {
case "slack":
backendSettings.webhookUrl = settings.slackWebhookUrl;
backendSettings.message =
settings.slackMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
backendSettings.channel = settings.slackChannel;
break;
case "kakao-talk":
backendSettings.accessToken = settings.kakaoAccessToken;
backendSettings.message =
settings.kakaoMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
break;
case "discord":
backendSettings.webhookUrl = settings.discordWebhookUrl;
backendSettings.message =
settings.discordMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
backendSettings.username = settings.discordUsername;
break;
case "generic":
default:
backendSettings.url = settings.apiUrl;
backendSettings.method = settings.httpMethod || "POST";
try {
backendSettings.headers = settings.headers ? JSON.parse(settings.headers) : {};
} catch (error) {
console.warn("Headers JSON 파싱 실패, 기본값 사용:", error);
backendSettings.headers = {};
}
backendSettings.body = settings.bodyTemplate || "{}";
break;
}
}
// 로딩 토스트 시작
loadingToastId = toast.loading("외부 호출 테스트 중...", {
duration: 12000, // 12초 후 자동으로 사라짐
});
// 타임아웃을 위한 Promise.race 사용
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("테스트 요청이 10초 내에 완료되지 않았습니다.")), 10000);
});
const testPromise = ExternalCallAPI.testExternalCall({
settings: backendSettings,
templateData: {
recordCount: 5,
tableName: "test_table",
timestamp: new Date().toISOString(),
message: "데이터플로우 테스트 실행",
},
});
const result = await Promise.race([testPromise, timeoutPromise]);
// 로딩 토스트 제거
if (loadingToastId) {
toast.dismiss(loadingToastId);
}
if (result.success && result.result?.success) {
toast.success("외부 호출 테스트 성공!", {
description: `응답 시간: ${result.result.executionTime}ms`,
duration: 4000,
});
} else {
toast.error("외부 호출 테스트 실패", {
description: result.result?.error || result.error || "알 수 없는 오류",
duration: 6000,
});
}
} catch (error) {
console.error("테스트 실행 중 오류:", error);
// 로딩 토스트 제거
if (loadingToastId) {
toast.dismiss(loadingToastId);
}
if (error instanceof Error) {
toast.error("테스트 실행 중 오류가 발생했습니다.", {
description: error.message,
duration: 6000,
});
} else {
toast.error("테스트 실행 중 알 수 없는 오류가 발생했습니다.", {
duration: 6000,
});
}
}
};
export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ settings, onSettingsChange }) => {
return (
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Globe className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleTestExternalCall(settings)}
className="h-7 px-2 text-xs"
>
</Button>
</div>
<div className="space-y-3">
<div>
@@ -147,7 +266,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
<>
<div>
<Label htmlFor="discordWebhookUrl" className="text-sm">
URL
URL <span className="text-red-500">*</span>
</Label>
<Input
id="discordWebhookUrl"
@@ -157,6 +276,18 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
className="text-sm"
/>
</div>
<div>
<Label htmlFor="discordUsername" className="text-sm">
</Label>
<Input
id="discordUsername"
value={settings.discordUsername || ""}
onChange={(e) => onSettingsChange({ ...settings, discordUsername: e.target.value })}
placeholder="ERP 시스템"
className="text-sm"
/>
</div>
<div>
<Label htmlFor="discordMessage" className="text-sm">

View File

@@ -0,0 +1,196 @@
/**
* 외부 호출 API 클라이언트
*/
// 백엔드 타입과 동일한 인터페이스 정의
export interface ExternalCallResult {
success: boolean;
statusCode?: number;
response?: string;
error?: string;
executionTime: number;
timestamp: string;
}
export interface ExternalCallTestRequest {
settings: Record<string, unknown>;
templateData?: Record<string, unknown>;
}
export interface ExternalCallExecuteRequest {
diagramId: number;
relationshipId: string;
settings: Record<string, unknown>;
templateData?: Record<string, unknown>;
}
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* 외부 호출 API 클래스
*/
// API URL 동적 설정 - 기존 client.ts와 동일한 로직
const getApiBaseUrl = (): string => {
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
const currentPort = window.location.port;
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
if (
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
(currentPort === "9771" || currentPort === "3000")
) {
return "http://localhost:8080/api";
}
// 서버 환경에서 localhost:5555 → 39.117.244.52:8080
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && currentPort === "5555") {
return "http://39.117.244.52:8080/api";
}
// 기타 서버 환경 (내부/외부 IP): → 39.117.244.52:8080
return "http://39.117.244.52:8080/api";
}
// 서버 사이드 렌더링 기본값
return "http://39.117.244.52:8080/api";
};
export class ExternalCallAPI {
private static readonly BASE_URL = `${getApiBaseUrl()}/external-calls`;
/**
* 외부 호출 테스트 실행
*/
static async testExternalCall(request: ExternalCallTestRequest): Promise<{
success: boolean;
result?: ExternalCallResult;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/test`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
// 응답이 JSON인지 확인
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
throw new Error(`서버에서 JSON이 아닌 응답을 반환했습니다: ${text.substring(0, 100)}...`);
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("외부 호출 테스트 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
/**
* 외부 호출 실행
*/
static async executeExternalCall(request: ExternalCallExecuteRequest): Promise<{
success: boolean;
result?: ExternalCallResult;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("외부 호출 실행 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
/**
* 지원되는 외부 호출 타입 목록 조회
*/
static async getSupportedTypes(): Promise<{
success: boolean;
supportedTypes?: Record<string, any>;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/types`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("지원 타입 조회 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
/**
* 외부 호출 설정 검증
*/
static async validateSettings(settings: Record<string, unknown>): Promise<{
success: boolean;
validation?: ValidationResult;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ settings }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("설정 검증 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
}

View File

@@ -18,17 +18,7 @@ const nextConfig = {
outputFileTracingRoot: undefined,
},
async rewrites() {
// 개발 환경과 운영 환경에 따른 백엔드 URL 설정
const backendUrl = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "http://backend:8080";
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
// 프록시 설정 제거 - 모든 API가 직접 백엔드 호출
// 개발 환경에서 CORS 처리
async headers() {

View File

@@ -68,10 +68,10 @@ export interface DataSaveSettings {
// 외부 호출 설정
export interface ExternalCallSettings {
callType: "rest-api" | "email" | "ftp" | "queue";
// REST API 세부 종류
apiType?: "slack" | "kakao-talk" | "discord" | "generic";
// 일반 REST API 설정
apiUrl?: string;
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
@@ -90,6 +90,7 @@ export interface ExternalCallSettings {
// 디스코드 전용 설정
discordWebhookUrl?: string;
discordMessage?: string;
discordUsername?: string;
}
// ConnectionSetupModal Props 타입