외부 db연동 구현

This commit is contained in:
dohyeons 2025-10-01 14:36:46 +09:00
parent 12087cbdd7
commit 2ee4dd0b58
8 changed files with 212 additions and 18 deletions

View File

@ -335,13 +335,14 @@ export class ReportController {
async executeQuery(req: Request, res: Response, next: NextFunction) {
try {
const { reportId, queryId } = req.params;
const { parameters = {}, sqlQuery } = req.body;
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
const result = await reportService.executeQuery(
reportId,
queryId,
parameters,
sqlQuery
sqlQuery,
externalConnectionId
);
return res.json({
@ -355,6 +356,31 @@ export class ReportController {
});
}
}
/**
* DB ( )
* GET /api/admin/reports/external-connections
*/
async getExternalConnections(
req: Request,
res: Response,
next: NextFunction
) {
try {
const { ExternalDbConnectionService } = await import(
"../services/externalDbConnectionService"
);
const result = await ExternalDbConnectionService.getConnections({
is_active: "Y",
company_code: req.body.companyCode || "",
});
return res.json(result);
} catch (error) {
return next(error);
}
}
}
export default new ReportController();

View File

@ -7,7 +7,12 @@ const router = Router();
// 모든 리포트 API는 인증이 필요
router.use(authenticateToken);
// 템플릿 관련 라우트 (구체적인 경로를 먼저 배치)
// 외부 DB 연결 목록 (구체적인 경로를 먼저 배치)
router.get("/external-connections", (req, res, next) =>
reportController.getExternalConnections(req, res, next)
);
// 템플릿 관련 라우트
router.get("/templates", (req, res, next) =>
reportController.getTemplates(req, res, next)
);

View File

@ -18,6 +18,8 @@ import {
GetTemplatesResponse,
CreateTemplateRequest,
} from "../types/report";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { ExternalDbConnectionService } from "./externalDbConnectionService";
export class ReportService {
/**
@ -165,6 +167,7 @@ export class ReportService {
query_type,
sql_query,
parameters,
external_connection_id,
display_order,
created_at,
created_by,
@ -449,9 +452,10 @@ export class ReportService {
query_type,
sql_query,
parameters,
external_connection_id,
display_order,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`;
for (const originalQuery of queriesResult.rows) {
@ -463,6 +467,7 @@ export class ReportService {
originalQuery.query_type,
originalQuery.sql_query,
JSON.stringify(originalQuery.parameters),
originalQuery.external_connection_id || null,
originalQuery.display_order,
userId,
]);
@ -595,9 +600,10 @@ export class ReportService {
query_type,
sql_query,
parameters,
external_connection_id,
display_order,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`;
for (let i = 0; i < data.queries.length; i++) {
@ -609,6 +615,7 @@ export class ReportService {
q.type,
q.sqlQuery,
JSON.stringify(q.parameters),
(q as any).externalConnectionId || null, // 외부 DB 연결 ID
i,
userId,
]);
@ -620,26 +627,34 @@ export class ReportService {
}
/**
*
* ( DB DB)
*/
async executeQuery(
reportId: string,
queryId: string,
parameters: Record<string, any>,
sqlQuery?: string
sqlQuery?: string,
externalConnectionId?: number | null
): Promise<{ fields: string[]; rows: any[] }> {
let sql_query: string;
let queryParameters: string[] = [];
let connectionId: number | null = externalConnectionId ?? null;
// 테스트 모드 (sqlQuery 직접 전달)
if (sqlQuery) {
sql_query = sqlQuery;
// 파라미터 순서 추출
// 파라미터 순서 추출 (등장 순서대로)
const matches = sqlQuery.match(/\$\d+/g);
if (matches) {
queryParameters = Array.from(new Set(matches)).sort((a, b) => {
return parseInt(a.substring(1)) - parseInt(b.substring(1));
});
const seen = new Set<string>();
const result: string[] = [];
for (const match of matches) {
if (!seen.has(match)) {
seen.add(match);
result.push(match);
}
}
queryParameters = result;
}
} else {
// DB에서 쿼리 조회
@ -656,6 +671,7 @@ export class ReportService {
queryParameters = Array.isArray(queryResult.parameters)
? queryResult.parameters
: [];
connectionId = queryResult.external_connection_id;
}
// 파라미터 배열 생성 ($1, $2 순서대로)
@ -665,8 +681,49 @@ export class ReportService {
}
try {
// 쿼리 실행
const result = await query(sql_query, paramArray);
let result: any[];
// 외부 DB 연결이 있으면 외부 DB에서 실행
if (connectionId) {
// 외부 DB 연결 정보 조회
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error("외부 DB 연결 정보를 찾을 수 없습니다.");
}
const connection = connectionResult.data;
// DatabaseConnectorFactory를 사용하여 외부 DB 쿼리 실행
const config = {
host: connection.host,
port: connection.port,
database: connection.database_name,
user: connection.username,
password: connection.password,
connectionTimeout: connection.connection_timeout || 30000,
queryTimeout: connection.query_timeout || 30000,
};
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
connectionId
);
await connector.connect();
try {
const queryResult = await connector.executeQuery(sql_query);
result = queryResult.rows || [];
} finally {
await connector.disconnect();
}
} else {
// 내부 DB에서 실행
result = await query(sql_query, paramArray);
}
// 필드명 추출
const fields = result.length > 0 ? Object.keys(result[0]) : [];

View File

@ -63,6 +63,7 @@ export interface ReportQuery {
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[] | null;
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
display_order: number;
created_at: Date;
created_by: string | null;

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -8,12 +8,13 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Plus, Trash2, Play, AlertCircle, Database } from "lucide-react";
import { Plus, Trash2, Play, AlertCircle, Database, Link2 } from "lucide-react";
import { useReportDesigner, ReportQuery } from "@/contexts/ReportDesignerContext";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import type { ExternalConnection } from "@/types/report";
export function QueryManager() {
const { queries, setQueries, reportId, setQueryResult, getQueryResult } = useReportDesigner();
@ -21,11 +22,32 @@ export function QueryManager() {
const [isTestRunning, setIsTestRunning] = useState(false);
const [parameterValues, setParameterValues] = useState<Record<string, string>>({});
const [parameterTypes, setParameterTypes] = useState<Record<string, string>>({});
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
const { toast } = useToast();
const selectedQuery = queries.find((q) => q.id === selectedQueryId);
const testResult = selectedQuery ? getQueryResult(selectedQuery.id) : null;
// 외부 DB 연결 목록 조회
useEffect(() => {
const fetchConnections = async () => {
setIsLoadingConnections(true);
try {
const response = await reportApi.getExternalConnections();
if (response.success && response.data) {
setExternalConnections(response.data);
}
} catch (error) {
console.error("외부 DB 연결 목록 조회 실패:", error);
} finally {
setIsLoadingConnections(false);
}
};
fetchConnections();
}, []);
// 파라미터 감지 ($1, $2 등, 단 작은따옴표 안은 제외)
const detectParameters = (sql: string): string[] => {
// 작은따옴표 안의 내용을 제거
@ -55,6 +77,7 @@ export function QueryManager() {
type: "MASTER",
sqlQuery: "",
parameters: [],
externalConnectionId: null, // 기본값: 내부 DB
};
setQueries([...queries, newQuery]);
setSelectedQueryId(newQuery.id);
@ -121,9 +144,16 @@ export function QueryManager() {
// new 리포트는 임시 ID 사용하고 SQL 쿼리 직접 전달
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
const sqlQuery = reportId === "new" ? selectedQuery.sqlQuery : undefined;
const externalConnectionId = (selectedQuery as any).externalConnectionId || null;
// 실제 API 호출
const response = await reportApi.executeQuery(testReportId, selectedQuery.id, parameterValues, sqlQuery);
const response = await reportApi.executeQuery(
testReportId,
selectedQuery.id,
parameterValues,
sqlQuery,
externalConnectionId,
);
if (response.success && response.data) {
// Context에 실행 결과 저장
@ -246,6 +276,51 @@ export function QueryManager() {
</Select>
</div>
{/* DB 연결 선택 */}
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs">
<Link2 className="h-3 w-3" />
DB
</Label>
<Select
value={(selectedQuery as any).externalConnectionId?.toString() || "internal"}
onValueChange={(value) =>
handleUpdateQuery(selectedQuery.id, {
externalConnectionId: value === "internal" ? null : parseInt(value),
} as any)
}
disabled={isLoadingConnections}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
DB (PostgreSQL)
</div>
</SelectItem>
{externalConnections.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500"> DB</div>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{conn.connection_name}
<Badge variant="outline" className="text-xs">
{conn.db_type.toUpperCase()}
</Badge>
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{/* SQL 쿼리 */}
<div className="space-y-2">
<Textarea

View File

@ -11,6 +11,7 @@ export interface ReportQuery {
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null; // 외부 DB 연결 ID
}
// 템플릿 레이아웃 정의

View File

@ -118,14 +118,33 @@ export const reportApi = {
},
// 쿼리 실행
executeQuery: async (reportId: string, queryId: string, parameters: Record<string, any>, sqlQuery?: string) => {
executeQuery: async (
reportId: string,
queryId: string,
parameters: Record<string, any>,
sqlQuery?: string,
externalConnectionId?: number | null,
) => {
const response = await apiClient.post<{
success: boolean;
data: {
fields: string[];
rows: any[];
};
}>(`${BASE_URL}/${reportId}/queries/${queryId}/execute`, { parameters, sqlQuery });
}>(`${BASE_URL}/${reportId}/queries/${queryId}/execute`, {
parameters,
sqlQuery,
externalConnectionId,
});
return response.data;
},
// 외부 DB 연결 목록 조회
getExternalConnections: async () => {
const response = await apiClient.get<{
success: boolean;
data: any[];
}>(`${BASE_URL}/external-connections`);
return response.data;
},
};

View File

@ -63,6 +63,7 @@ export interface ReportQuery {
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[];
external_connection_id: number | null; // 외부 DB 연결 ID
display_order: number;
created_at: string;
created_by: string | null;
@ -70,6 +71,15 @@ export interface ReportQuery {
updated_by: string | null;
}
// 외부 DB 연결 (간단한 버전)
export interface ExternalConnection {
id: number;
connection_name: string;
db_type: string;
description?: string;
is_active: string;
}
// 컴포넌트 설정
export interface ComponentConfig {
id: string;