From 50dbf1f73824b6dcd0f270494e4b107f0847a904 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 10 Nov 2025 18:49:44 +0900 Subject: [PATCH 01/24] =?UTF-8?q?=EB=8F=84=EC=BB=A4=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab7a327e..27f77c21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,9 +66,9 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs -# 업로드 디렉토리 생성 (백엔드용) -RUN mkdir -p /app/backend/uploads && \ - chown -R nodejs:nodejs /app/backend/uploads +# 업로드 및 로그 디렉토리 생성 (백엔드용) +RUN mkdir -p /app/backend/uploads /app/backend/logs && \ + chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs # 시작 스크립트 생성 RUN echo '#!/bin/sh' > /app/start.sh && \ From f340b1ac050fcbbc4e4772d33de6149eea2fc614 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 10:13:41 +0900 Subject: [PATCH 02/24] =?UTF-8?q?log=20=EB=94=94=EB=A0=89=ED=84=B0?= =?UTF-8?q?=EB=A6=AC=20=EA=B6=8C=ED=95=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27f77c21..ca0d14a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,9 +66,15 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs -# 업로드 및 로그 디렉토리 생성 (백엔드용) -RUN mkdir -p /app/backend/uploads /app/backend/logs && \ - chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs +# 백엔드 디렉토리 생성 (업로드, 로그, 데이터) +RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data && \ + chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data && \ + chmod -R 755 /app/backend + +# 프론트엔드 standalone 모드를 위한 디렉토리 생성 +RUN mkdir -p /app/frontend/data && \ + chown -R nodejs:nodejs /app/frontend && \ + chmod -R 755 /app/frontend # 시작 스크립트 생성 RUN echo '#!/bin/sh' > /app/start.sh && \ @@ -83,11 +89,7 @@ RUN echo '#!/bin/sh' > /app/start.sh && \ echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \ echo 'cd /app/frontend' >> /app/start.sh && \ echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \ - echo 'npm start &' >> /app/start.sh && \ - echo 'FRONTEND_PID=$!' >> /app/start.sh && \ - echo '' >> /app/start.sh && \ - echo '# 프로세스 모니터링' >> /app/start.sh && \ - echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \ + echo 'exec npm start' >> /app/start.sh && \ chmod +x /app/start.sh && \ chown nodejs:nodejs /app/start.sh From 70e97aa4a2ed038bce4fd54413d9e9342f6fe2dc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 11:26:26 +0900 Subject: [PATCH 03/24] =?UTF-8?q?=EB=8F=84=EC=BB=A4=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95(=EC=9E=84=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Dockerfile b/Dockerfile index ca0d14a0..8dbf67ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,6 +93,25 @@ RUN echo '#!/bin/sh' > /app/start.sh && \ chmod +x /app/start.sh && \ chown nodejs:nodejs /app/start.sh +# ============================================================ +# 환경변수 설정 (임시 조치) +# helm-charts의 values_logistream.yaml 관리자가 설정 완료 시 삭제 예정 +# ============================================================ +ENV NODE_ENV=production \ + LOG_LEVEL=info \ + PORT=8080 \ + HOST=0.0.0.0 \ + DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \ + JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \ + ENCRYPTION_KEY="ilshin-plm-mail-encryption-key-32characters-2024-secure" \ + JWT_EXPIRES_IN="24h" \ + CORS_CREDENTIALS="true" \ + CORS_ORIGIN="https://logistream.kpslp.kr" \ + KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \ + ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \ + NEXT_TELEMETRY_DISABLED="1" \ + NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api" + # 비특권 사용자로 전환 USER nodejs From 802cda7348a4cff32a0630000b2285d1198a9d48 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 11:52:53 +0900 Subject: [PATCH 04/24] =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8dbf67ae..9018f249 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,9 +67,12 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./fronte COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs # 백엔드 디렉토리 생성 (업로드, 로그, 데이터) -RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data && \ - chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data && \ - chmod -R 755 /app/backend +# /app/uploads 경로는 백엔드 코드에서 사용 (mailTemplateFileService 등) +RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \ + /app/uploads/mail-templates /app/uploads/mail-accounts && \ + chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data \ + /app/uploads && \ + chmod -R 755 /app/backend /app/uploads # 프론트엔드 standalone 모드를 위한 디렉토리 생성 RUN mkdir -p /app/frontend/data && \ From 12b5c4243a99b3cd0f8fecd012c8dba334b61a2a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 12:23:21 +0900 Subject: [PATCH 05/24] =?UTF-8?q?uuid=EB=B2=84=EC=A0=84=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-node/package.json b/backend-node/package.json index bacd9fb3..871c7212 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -48,7 +48,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", - "uuid": "^13.0.0", + "uuid": "^9.0.1", "winston": "^3.11.0" }, "devDependencies": { From 8508e64ab301f5c88e531bc611c27f1494ce7da9 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 12:26:36 +0900 Subject: [PATCH 06/24] Fix: Update package-lock.json for uuid@9.0.1 --- backend-node/package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index b9528ee0..b0472294 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -34,7 +34,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", - "uuid": "^13.0.0", + "uuid": "^9.0.1", "winston": "^3.11.0" }, "devDependencies": { @@ -10642,16 +10642,16 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { From b47f34c6165d70361e0b4d0ccd3b0755a890d990 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 12:44:28 +0900 Subject: [PATCH 07/24] =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9018f249..36aa0263 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,12 +67,13 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./fronte COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs # 백엔드 디렉토리 생성 (업로드, 로그, 데이터) -# /app/uploads 경로는 백엔드 코드에서 사용 (mailTemplateFileService 등) +# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성 +# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함 RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \ - /app/uploads/mail-templates /app/uploads/mail-accounts && \ - chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data \ - /app/uploads && \ - chmod -R 755 /app/backend /app/uploads + /app/uploads /app/data && \ + chown -R nodejs:nodejs /app/backend /app/uploads /app/data && \ + chmod -R 777 /app/uploads /app/data && \ + chmod -R 755 /app/backend # 프론트엔드 standalone 모드를 위한 디렉토리 생성 RUN mkdir -p /app/frontend/data && \ From 198f9a6f2bb7c77d29f4ff19a8c9bab817156dc4 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 13:14:25 +0900 Subject: [PATCH 08/24] =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 36aa0263..73653080 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,13 +87,13 @@ RUN echo '#!/bin/sh' > /app/start.sh && \ echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \ echo 'cd /app/backend' >> /app/start.sh && \ echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \ - echo 'node dist/app.js &' >> /app/start.sh && \ + echo 'PORT=8080 node dist/app.js &' >> /app/start.sh && \ echo 'BACKEND_PID=$!' >> /app/start.sh && \ echo '' >> /app/start.sh && \ echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \ echo 'cd /app/frontend' >> /app/start.sh && \ echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \ - echo 'exec npm start' >> /app/start.sh && \ + echo 'PORT=3000 exec npm start' >> /app/start.sh && \ chmod +x /app/start.sh && \ chown nodejs:nodejs /app/start.sh @@ -103,7 +103,6 @@ RUN echo '#!/bin/sh' > /app/start.sh && \ # ============================================================ ENV NODE_ENV=production \ LOG_LEVEL=info \ - PORT=8080 \ HOST=0.0.0.0 \ DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \ JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \ From 3153cf03832ddf2a216a504d9c6434c3ed5e6d81 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 13:49:56 +0900 Subject: [PATCH 09/24] =?UTF-8?q?=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 73653080..ed23aa6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,9 +121,10 @@ USER nodejs # 포트 노출 EXPOSE 3000 8080 -# 헬스체크 -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 +# 헬스체크 (백엔드와 프론트엔드 둘 다 확인) +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health && \ + wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 # 컨테이너 시작 CMD ["/app/start.sh"] From cf2b5d4e8013d352d46058efd952eceb09d27766 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 16:59:16 +0900 Subject: [PATCH 10/24] =?UTF-8?q?url=EC=9D=84=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/api/client.ts | 5 +++++ frontend/lib/utils/apiUrl.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7dc811c9..7279092e 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -13,6 +13,11 @@ const getApiBaseUrl = (): string => { const currentPort = window.location.port; const protocol = window.location.protocol; + // 프로덕션 환경: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트 + if (currentHost === "logistream.kpslp.kr") { + return `${protocol}//${currentHost}:8080/api`; + } + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com if (currentHost === "v1.vexplor.com") { return "https://api.vexplor.com/api"; diff --git a/frontend/lib/utils/apiUrl.ts b/frontend/lib/utils/apiUrl.ts index ea334b86..c50dc533 100644 --- a/frontend/lib/utils/apiUrl.ts +++ b/frontend/lib/utils/apiUrl.ts @@ -8,6 +8,11 @@ export function getApiUrl(endpoint: string): string { if (typeof window !== "undefined") { const hostname = window.location.hostname; + // 프로덕션: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트 + if (hostname === "logistream.kpslp.kr") { + return `https://logistream.kpslp.kr:8080${endpoint}`; + } + // 프로덕션: v1.vexplor.com → https://api.vexplor.com if (hostname === "v1.vexplor.com") { return `https://api.vexplor.com${endpoint}`; From c669374156a1dba45dfc6ce2b40c4461629c7b9f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 17:20:17 +0900 Subject: [PATCH 11/24] =?UTF-8?q?NEXT=5FPUBLIC=5FAPI=5FURL=EC=97=90=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=208080=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ed23aa6f..eba3aeb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -113,7 +113,7 @@ ENV NODE_ENV=production \ KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \ ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \ NEXT_TELEMETRY_DISABLED="1" \ - NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api" + NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api" # 비특권 사용자로 전환 USER nodejs From 1514af23831a0c5e3e0e24da7872d2871024edb1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 17:49:41 +0900 Subject: [PATCH 12/24] =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=EC=97=90=20NEXT=5FPUBLIC=5FAPI=5FURL=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 ++ frontend/hooks/useLogin.ts | 5 ++++- frontend/lib/api/client.ts | 16 +++++++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index eba3aeb0..609384d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,8 +39,10 @@ RUN npm ci && \ COPY frontend/ ./ # Next.js 프로덕션 빌드 (린트 비활성화) +# 빌드 시점에 환경변수 설정 (번들에 포함됨) ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production +ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api" RUN npm run build:no-lint # ------------------------------ diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 09c32d5f..02837bc9 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -64,7 +64,10 @@ export const useLogin = () => { // 로컬 스토리지에서 토큰 가져오기 const token = localStorage.getItem("authToken"); - const response = await fetch(`${API_BASE_URL}${endpoint}`, { + // API URL 동적 계산 (매번 호출 시마다) + const apiBaseUrl = API_BASE_URL; + + const response = await fetch(`${apiBaseUrl}${endpoint}`, { credentials: "include", headers: { "Content-Type": "application/json", diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7279092e..d3afe83b 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -32,11 +32,21 @@ const getApiBaseUrl = (): string => { } } - // 3. 기본값 - return "http://localhost:8080/api"; + // 3. 기본값 (서버사이드 빌드 시) + return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api"; }; -export const API_BASE_URL = getApiBaseUrl(); +// 매번 호출 시 동적으로 계산 (getter 함수) +export const getAPIBaseURL = getApiBaseUrl; + +// 하위 호환성을 위해 유지하되, 동적으로 계산되도록 수정 +let _cachedApiBaseUrl: string | null = null; +export const API_BASE_URL = (() => { + if (_cachedApiBaseUrl === null || typeof window !== "undefined") { + _cachedApiBaseUrl = getApiBaseUrl(); + } + return _cachedApiBaseUrl; +})(); // 이미지 URL을 완전한 URL로 변환하는 함수 export const getFullImageUrl = (imagePath: string): string => { From ff21a84932b686260cfab5ba8d584b1b52fd5874 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 11 Nov 2025 18:42:35 +0900 Subject: [PATCH 13/24] =?UTF-8?q?8080=ED=8F=AC=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 ++-- frontend/lib/api/client.ts | 4 ++-- frontend/lib/utils/apiUrl.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 609384d6..09fceefb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ COPY frontend/ ./ # 빌드 시점에 환경변수 설정 (번들에 포함됨) ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api" +ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api" RUN npm run build:no-lint # ------------------------------ @@ -115,7 +115,7 @@ ENV NODE_ENV=production \ KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \ ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \ NEXT_TELEMETRY_DISABLED="1" \ - NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api" + NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api" # 비특권 사용자로 전환 USER nodejs diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index d3afe83b..97179313 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -13,9 +13,9 @@ const getApiBaseUrl = (): string => { const currentPort = window.location.port; const protocol = window.location.protocol; - // 프로덕션 환경: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트 + // 프로덕션 환경: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음) if (currentHost === "logistream.kpslp.kr") { - return `${protocol}//${currentHost}:8080/api`; + return `${protocol}//${currentHost}/api`; } // 프로덕션 환경: v1.vexplor.com → api.vexplor.com diff --git a/frontend/lib/utils/apiUrl.ts b/frontend/lib/utils/apiUrl.ts index c50dc533..6984b1bc 100644 --- a/frontend/lib/utils/apiUrl.ts +++ b/frontend/lib/utils/apiUrl.ts @@ -8,9 +8,9 @@ export function getApiUrl(endpoint: string): string { if (typeof window !== "undefined") { const hostname = window.location.hostname; - // 프로덕션: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트 + // 프로덕션: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음) if (hostname === "logistream.kpslp.kr") { - return `https://logistream.kpslp.kr:8080${endpoint}`; + return `https://logistream.kpslp.kr${endpoint}`; } // 프로덕션: v1.vexplor.com → https://api.vexplor.com From 22b6404a5b40908ea60be119073881a15793de21 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 13 Nov 2025 16:06:39 +0900 Subject: [PATCH 14/24] =?UTF-8?q?logistream=20=EA=B3=B5=EC=B0=A8=EC=A4=91?= =?UTF-8?q?=EA=B3=84=EC=9A=A9=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/authController.ts | 67 +++++ backend-node/src/routes/authRoutes.ts | 6 + backend-node/src/services/authService.ts | 73 ++++++ frontend/app/(auth)/signup/page.tsx | 49 ++++ frontend/components/auth/LoginForm.tsx | 14 + frontend/components/auth/SignupForm.tsx | 244 ++++++++++++++++++ frontend/hooks/useSignup.ts | 235 +++++++++++++++++ frontend/lib/api/auth.ts | 21 ++ frontend/types/auth.ts | 18 ++ 9 files changed, 727 insertions(+) create mode 100644 frontend/app/(auth)/signup/page.tsx create mode 100644 frontend/components/auth/SignupForm.tsx create mode 100644 frontend/hooks/useSignup.ts create mode 100644 frontend/lib/api/auth.ts diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 374015ee..2d574cc7 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -384,4 +384,71 @@ export class AuthController { }); } } + + /** + * POST /api/auth/signup + * 회원가입 API + */ + static async signup(req: Request, res: Response): Promise { + try { + const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber } = req.body; + + logger.info(`=== 회원가입 API 호출 ===`); + logger.info(`userId: ${userId}`); + + // 입력값 검증 + if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) { + res.status(400).json({ + success: false, + message: "모든 필수 항목을 입력해주세요.", + error: { + code: "INVALID_INPUT", + details: "필수 입력값이 누락되었습니다.", + }, + }); + return; + } + + // 회원가입 처리 + const signupResult = await AuthService.signupUser({ + userId, + password, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + }); + + if (signupResult.success) { + logger.info(`회원가입 성공: ${userId}`); + res.status(201).json({ + success: true, + message: "회원가입이 완료되었습니다.", + data: { + userId, + }, + }); + } else { + logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`); + res.status(400).json({ + success: false, + message: signupResult.message || "회원가입에 실패했습니다.", + error: { + code: "SIGNUP_FAILED", + details: signupResult.message, + }, + }); + } + } catch (error: any) { + logger.error("회원가입 오류:", error); + res.status(500).json({ + success: false, + message: "회원가입 처리 중 오류가 발생했습니다.", + error: { + code: "SIGNUP_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }, + }); + } + } } diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 29bc7944..357bfc8e 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout); */ router.post("/refresh", AuthController.refreshToken); +/** + * POST /api/auth/signup + * 회원가입 API + */ +router.post("/signup", AuthController.signup); + export default router; diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 11e34576..1bf4dd40 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -342,4 +342,77 @@ export class AuthService { ); } } + + /** + * 회원가입 처리 + */ + static async signupUser(data: { + userId: string; + password: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; + }): Promise<{ success: boolean; message?: string }> { + try { + const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber } = data; + + // 1. 중복 사용자 확인 + const existingUser = await query( + `SELECT user_id FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (existingUser.length > 0) { + return { + success: false, + message: "이미 존재하는 아이디입니다.", + }; + } + + // 2. 비밀번호 암호화 + const bcrypt = require("bcryptjs"); + const hashedPassword = await bcrypt.hash(password, 10); + + // 3. 사용자 정보 저장 + await query( + `INSERT INTO user_info ( + user_id, + user_password, + user_name, + cell_phone, + license_number, + vehicle_number, + company_code, + user_type, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`, + [ + userId, + hashedPassword, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + "*", // 기본 회사 코드 + null, // user_type: null + "active", // status: active + ] + ); + + logger.info(`회원가입 성공: ${userId}`); + + return { + success: true, + message: "회원가입이 완료되었습니다.", + }; + } catch (error: any) { + logger.error("회원가입 오류:", error); + return { + success: false, + message: error.message || "회원가입 중 오류가 발생했습니다.", + }; + } + } } diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..ebfed844 --- /dev/null +++ b/frontend/app/(auth)/signup/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useSignup } from "@/hooks/useSignup"; +import { LoginHeader } from "@/components/auth/LoginHeader"; +import { SignupForm } from "@/components/auth/SignupForm"; +import { LoginFooter } from "@/components/auth/LoginFooter"; + +/** + * 회원가입 페이지 컴포넌트 + */ +export default function SignupPage() { + const { + formData, + isLoading, + error, + showPassword, + validationErrors, + touchedFields, + isFormValid, + handleInputChange, + handleBlur, + handleSignup, + togglePasswordVisibility, + } = useSignup(); + + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index dda3736f..9d82895a 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Eye, EyeOff, Loader2 } from "lucide-react"; import { LoginFormData } from "@/types/auth"; import { ErrorMessage } from "./ErrorMessage"; +import { useRouter } from "next/navigation"; interface LoginFormProps { formData: LoginFormData; @@ -28,6 +29,8 @@ export function LoginForm({ onSubmit, onTogglePassword, }: LoginFormProps) { + const router = useRouter(); + return ( @@ -97,6 +100,17 @@ export function LoginForm({ "로그인" )} + + {/* 회원가입 버튼 */} + diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx new file mode 100644 index 00000000..e7c8f267 --- /dev/null +++ b/frontend/components/auth/SignupForm.tsx @@ -0,0 +1,244 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react"; +import { SignupFormData } from "@/types/auth"; +import { ErrorMessage } from "./ErrorMessage"; +import { useRouter } from "next/navigation"; + +interface SignupFormProps { + formData: SignupFormData; + isLoading: boolean; + error: string; + showPassword: boolean; + validationErrors: Record; + touchedFields: Record; + isFormValid: boolean; + onInputChange: (e: React.ChangeEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onTogglePassword: () => void; +} + +/** + * 회원가입 폼 컴포넌트 + */ +export function SignupForm({ + formData, + isLoading, + error, + showPassword, + validationErrors, + touchedFields, + isFormValid, + onInputChange, + onBlur, + onSubmit, + onTogglePassword, +}: SignupFormProps) { + const router = useRouter(); + + return ( + + + 회원가입 + 새로운 계정을 만들어보세요 + + + + +
+ {/* 아이디 */} +
+ + + {touchedFields?.userId && validationErrors.userId && ( +

{validationErrors.userId}

+ )} +
+ + {/* 비밀번호 */} +
+ +
+ + +
+ {touchedFields?.password && validationErrors.password && ( +

{validationErrors.password}

+ )} +
+ + {/* 비밀번호 확인 */} +
+ +
+ + +
+ {touchedFields?.passwordConfirm && validationErrors.passwordConfirm && ( +

{validationErrors.passwordConfirm}

+ )} +
+ + {/* 이름 */} +
+ + + {touchedFields?.userName && validationErrors.userName && ( +

{validationErrors.userName}

+ )} +
+ + {/* 연락처 */} +
+ + + {touchedFields?.phoneNumber && validationErrors.phoneNumber && ( +

{validationErrors.phoneNumber}

+ )} +
+ + {/* 면허번호 */} +
+ + + {touchedFields?.licenseNumber && validationErrors.licenseNumber && ( +

{validationErrors.licenseNumber}

+ )} +
+ + {/* 차량번호 */} +
+ + + {touchedFields?.vehicleNumber && validationErrors.vehicleNumber && ( +

{validationErrors.vehicleNumber}

+ )} +
+ + {/* 회원가입 버튼 */} + + + {/* 로그인으로 돌아가기 버튼 */} + +
+
+
+ ); +} + diff --git a/frontend/hooks/useSignup.ts b/frontend/hooks/useSignup.ts new file mode 100644 index 00000000..6327320b --- /dev/null +++ b/frontend/hooks/useSignup.ts @@ -0,0 +1,235 @@ +import { useState, useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { SignupFormData } from "@/types/auth"; +import { signupUser } from "@/lib/api/auth"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 유효성 검사 함수들 + */ +const validators = { + // 연락처: 010-1234-5678 형식 + phoneNumber: (value: string): string | null => { + const phoneRegex = /^01[0-9]-\d{3,4}-\d{4}$/; + if (!value) return "연락처를 입력해주세요"; + if (!phoneRegex.test(value)) return "올바른 연락처 형식이 아닙니다 (예: 010-1234-5678)"; + return null; + }, + + // 면허번호: 12-34-567890-12 형식 + licenseNumber: (value: string): string | null => { + const licenseRegex = /^\d{2}-\d{2}-\d{6}-\d{2}$/; + if (!value) return "면허번호를 입력해주세요"; + if (!licenseRegex.test(value)) return "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)"; + return null; + }, + + // 차량번호: 12가1234 형식 + vehicleNumber: (value: string): string | null => { + const vehicleRegex = /^\d{2,3}[가-힣]\d{4}$/; + if (!value) return "차량번호를 입력해주세요"; + if (!vehicleRegex.test(value)) return "올바른 차량번호 형식이 아닙니다 (예: 12가1234)"; + return null; + }, + + // 아이디: 4자 이상 + userId: (value: string): string | null => { + if (!value) return "아이디를 입력해주세요"; + if (value.length < 4) return "아이디는 4자 이상이어야 합니다"; + return null; + }, + + // 비밀번호: 6자 이상 + password: (value: string): string | null => { + if (!value) return "비밀번호를 입력해주세요"; + if (value.length < 6) return "비밀번호는 6자 이상이어야 합니다"; + return null; + }, + + // 비밀번호 확인: password와 일치해야 함 + passwordConfirm: (value: string, password?: string): string | null => { + if (!value) return "비밀번호 확인을 입력해주세요"; + if (password && value !== password) return "비밀번호가 일치하지 않습니다"; + return null; + }, + + // 이름: 2자 이상 + userName: (value: string): string | null => { + if (!value) return "이름을 입력해주세요"; + if (value.length < 2) return "이름은 2자 이상이어야 합니다"; + return null; + }, +}; + +/** + * 회원가입 커스텀 훅 + */ +export function useSignup() { + const router = useRouter(); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const [formData, setFormData] = useState({ + userId: "", + password: "", + passwordConfirm: "", + userName: "", + phoneNumber: "", + licenseNumber: "", + vehicleNumber: "", + }); + + const [validationErrors, setValidationErrors] = useState>({}); + const [touchedFields, setTouchedFields] = useState>({}); + const [isFormValid, setIsFormValid] = useState(false); + + // 유효성 검사 실행 + const validateField = useCallback((name: string, value: string, password?: string) => { + const validator = validators[name as keyof typeof validators]; + if (validator) { + // passwordConfirm 검증 시 password도 함께 전달 + if (name === "passwordConfirm") { + return validator(value, password); + } + return validator(value); + } + return null; + }, []); + + // 전체 폼 유효성 검사 + const validateForm = useCallback(() => { + const errors: Record = {}; + let hasErrors = false; + + Object.keys(formData).forEach((key) => { + const error = validateField(key, formData[key as keyof SignupFormData], formData.password); + if (error) { + errors[key] = error; + hasErrors = true; + } + }); + + setValidationErrors(errors); + setIsFormValid(!hasErrors && Object.values(formData).every((value) => value !== "")); + }, [formData, validateField]); + + // 입력 변경 핸들러 + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + + // touched된 필드만 실시간 유효성 검사 + if (touchedFields[name]) { + const fieldError = validateField(name, value, newFormData.password); + setValidationErrors((prev) => ({ + ...prev, + [name]: fieldError || "", + })); + } + + // password가 변경되면 passwordConfirm도 다시 검증 + if (name === "password" && touchedFields.passwordConfirm) { + const confirmError = validateField("passwordConfirm", newFormData.passwordConfirm, value); + setValidationErrors((prev) => ({ + ...prev, + passwordConfirm: confirmError || "", + })); + } + + setError(""); + }, + [validateField, touchedFields, formData], + ); + + // 포커스 아웃 핸들러 (Blur) + const handleBlur = useCallback( + (e: React.FocusEvent) => { + const { name, value } = e.target; + + // 필드를 touched로 표시 + setTouchedFields((prev) => ({ ...prev, [name]: true })); + + // 유효성 검사 실행 + const fieldError = validateField(name, value, formData.password); + setValidationErrors((prev) => ({ + ...prev, + [name]: fieldError || "", + })); + }, + [validateField, formData.password], + ); + + // 회원가입 제출 + const handleSignup = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + // 모든 필드를 touched로 표시 (제출 시 모든 에러 표시) + const allTouched = Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {}); + setTouchedFields(allTouched); + + // 최종 유효성 검사 + validateForm(); + + if (!isFormValid) { + setError("입력 정보를 다시 확인해주세요"); + return; + } + + setIsLoading(true); + + try { + const response = await signupUser(formData); + + if (response.success) { + toast({ + title: "회원가입 성공", + description: "로그인 페이지로 이동합니다", + }); + + // 1초 후 로그인 페이지로 이동 + setTimeout(() => { + router.push("/login"); + }, 1000); + } else { + setError(response.message || "회원가입에 실패했습니다"); + } + } catch (err: any) { + console.error("회원가입 오류:", err); + setError(err.message || "회원가입 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }, + [formData, isFormValid, router, toast, validateForm], + ); + + // 비밀번호 표시 토글 + const togglePasswordVisibility = useCallback(() => { + setShowPassword((prev) => !prev); + }, []); + + // 폼 데이터 변경 시 유효성 검사 실행 + useEffect(() => { + validateForm(); + }, [formData, validateForm]); + + return { + formData, + isLoading, + error, + showPassword, + validationErrors, + touchedFields, + isFormValid, + handleInputChange, + handleBlur, + handleSignup, + togglePasswordVisibility, + }; +} diff --git a/frontend/lib/api/auth.ts b/frontend/lib/api/auth.ts new file mode 100644 index 00000000..c3f701e0 --- /dev/null +++ b/frontend/lib/api/auth.ts @@ -0,0 +1,21 @@ +import { apiClient } from "./client"; +import { SignupFormData, SignupResponse } from "@/types/auth"; + +/** + * 회원가입 API + */ +export async function signupUser(data: SignupFormData): Promise { + try { + const response = await apiClient.post("/auth/signup", data); + return response.data; + } catch (error: any) { + if (error.response?.data) { + return error.response.data; + } + return { + success: false, + message: error.message || "회원가입 중 오류가 발생했습니다", + }; + } +} + diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index cd8e65b6..f864df5b 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -7,6 +7,16 @@ export interface LoginFormData { password: string; } +export interface SignupFormData { + userId: string; + password: string; + passwordConfirm: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; +} + export interface LoginResponse { success: boolean; message?: string; @@ -18,6 +28,14 @@ export interface LoginResponse { errorCode?: string; } +export interface SignupResponse { + success: boolean; + message?: string; + data?: { + userId: string; + }; +} + export interface AuthStatus { isLoggedIn: boolean; isAdmin?: boolean; From 0b61ef4d1225960ad325fe7eab4015797f6d7f66 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 13 Nov 2025 18:25:50 +0900 Subject: [PATCH 15/24] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/auth/SignupForm.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx index e7c8f267..7ed5e846 100644 --- a/frontend/components/auth/SignupForm.tsx +++ b/frontend/components/auth/SignupForm.tsx @@ -65,7 +65,7 @@ export function SignupForm({ required /> {touchedFields?.userId && validationErrors.userId && ( -

{validationErrors.userId}

+

{validationErrors.userId}

)} @@ -95,7 +95,7 @@ export function SignupForm({ {touchedFields?.password && validationErrors.password && ( -

{validationErrors.password}

+

{validationErrors.password}

)} @@ -125,7 +125,7 @@ export function SignupForm({ {touchedFields?.passwordConfirm && validationErrors.passwordConfirm && ( -

{validationErrors.passwordConfirm}

+

{validationErrors.passwordConfirm}

)} @@ -145,7 +145,7 @@ export function SignupForm({ required /> {touchedFields?.userName && validationErrors.userName && ( -

{validationErrors.userName}

+

{validationErrors.userName}

)} @@ -165,7 +165,7 @@ export function SignupForm({ required /> {touchedFields?.phoneNumber && validationErrors.phoneNumber && ( -

{validationErrors.phoneNumber}

+

{validationErrors.phoneNumber}

)} @@ -185,7 +185,7 @@ export function SignupForm({ required /> {touchedFields?.licenseNumber && validationErrors.licenseNumber && ( -

{validationErrors.licenseNumber}

+

{validationErrors.licenseNumber}

)} @@ -205,7 +205,7 @@ export function SignupForm({ required /> {touchedFields?.vehicleNumber && validationErrors.vehicleNumber && ( -

{validationErrors.vehicleNumber}

+

{validationErrors.vehicleNumber}

)} @@ -221,7 +221,7 @@ export function SignupForm({ 가입 중... ) : ( - "회원가입" + "회원가입(공차중계)" )} @@ -241,4 +241,3 @@ export function SignupForm({ ); } - From 39d327fb45c402b8913b5559809a4990298c816d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 28 Nov 2025 11:35:36 +0900 Subject: [PATCH 16/24] =?UTF-8?q?=EC=99=B8=EB=B6=80=20REST=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/DashboardController.ts | 177 ++++++++++------ .../externalRestApiConnectionService.ts | 199 ++++++++++-------- .../dashboard/data-sources/MultiApiConfig.tsx | 78 ++++++- frontend/components/admin/dashboard/types.ts | 5 +- frontend/lib/api/externalDbConnection.ts | 3 + 5 files changed, 308 insertions(+), 154 deletions(-) diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 521f5250..01ac16c0 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -1,4 +1,7 @@ import { Response } from "express"; +import https from "https"; +import axios, { AxiosRequestConfig } from "axios"; +import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; import { DashboardService } from "../services/DashboardService"; import { @@ -7,6 +10,7 @@ import { DashboardListQuery, } from "../types/dashboard"; import { PostgreSQLService } from "../database/PostgreSQLService"; +import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService"; /** * 대시보드 컨트롤러 @@ -590,7 +594,14 @@ export class DashboardController { res: Response ): Promise { try { - const { url, method = "GET", headers = {}, queryParams = {} } = req.body; + const { + url, + method = "GET", + headers = {}, + queryParams = {}, + body, + externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함 + } = req.body; if (!url || typeof url !== "string") { res.status(400).json({ @@ -608,85 +619,131 @@ export class DashboardController { } }); - // 외부 API 호출 (타임아웃 30초) - // @ts-ignore - node-fetch dynamic import - const fetch = (await import("node-fetch")).default; - - // 타임아웃 설정 (Node.js 글로벌 AbortController 사용) - const controller = new (global as any).AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림) - - let response; - try { - response = await fetch(urlObj.toString(), { - method: method.toUpperCase(), - headers: { - "Content-Type": "application/json", - ...headers, - }, - signal: controller.signal, - }); - clearTimeout(timeoutId); - } catch (err: any) { - clearTimeout(timeoutId); - if (err.name === 'AbortError') { - throw new Error('외부 API 요청 타임아웃 (30초 초과)'); + // Axios 요청 설정 + const requestConfig: AxiosRequestConfig = { + url: urlObj.toString(), + method: method.toUpperCase(), + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...headers, + }, + timeout: 60000, // 60초 타임아웃 + validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) + }; + + // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 + if (externalConnectionId) { + try { + // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도 + let companyCode = req.user?.companyCode; + + if (!companyCode) { + companyCode = "*"; + } + + // 커넥션 로드 + const connectionResult = + await ExternalRestApiConnectionService.getConnectionById( + Number(externalConnectionId), + companyCode + ); + + if (connectionResult.success && connectionResult.data) { + const connection = connectionResult.data; + + // 인증 헤더 생성 (DB 토큰 등) + const authHeaders = + await ExternalRestApiConnectionService.getAuthHeaders( + connection.auth_type, + connection.auth_config, + connection.company_code + ); + + // 기존 헤더에 인증 헤더 병합 + requestConfig.headers = { + ...requestConfig.headers, + ...authHeaders, + }; + + // API Key가 Query Param인 경우 처리 + if ( + connection.auth_type === "api-key" && + connection.auth_config?.keyLocation === "query" && + connection.auth_config?.keyName && + connection.auth_config?.keyValue + ) { + const currentUrl = new URL(requestConfig.url!); + currentUrl.searchParams.append( + connection.auth_config.keyName, + connection.auth_config.keyValue + ); + requestConfig.url = currentUrl.toString(); + } + } + } catch (connError) { + logger.error( + `외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`, + connError + ); } - throw err; } - if (!response.ok) { + // Body 처리 + if (body) { + requestConfig.data = body; + } + + // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) + // ExternalRestApiConnectionService와 동일한 로직 적용 + const bypassDomains = ["thiratis.com"]; + const hostname = urlObj.hostname; + const shouldBypassTls = bypassDomains.some((domain) => + hostname.includes(domain) + ); + + if (shouldBypassTls) { + requestConfig.httpsAgent = new https.Agent({ + rejectUnauthorized: false, + }); + } + + const response = await axios(requestConfig); + + if (response.status >= 400) { throw new Error( `외부 API 오류: ${response.status} ${response.statusText}` ); } - // Content-Type에 따라 응답 파싱 - const contentType = response.headers.get("content-type"); - let data: any; + let data = response.data; + const contentType = response.headers["content-type"]; - // 한글 인코딩 처리 (EUC-KR → UTF-8) - const isKoreanApi = urlObj.hostname.includes('kma.go.kr') || - urlObj.hostname.includes('data.go.kr'); - - if (isKoreanApi) { - // 한국 정부 API는 EUC-KR 인코딩 사용 - const buffer = await response.arrayBuffer(); - const decoder = new TextDecoder('euc-kr'); - const text = decoder.decode(buffer); - - try { - data = JSON.parse(text); - } catch { - data = { text, contentType }; - } - } else if (contentType && contentType.includes("application/json")) { - data = await response.json(); - } else if (contentType && contentType.includes("text/")) { - // 텍스트 응답 (CSV, 일반 텍스트 등) - const text = await response.text(); - data = { text, contentType }; - } else { - // 기타 응답 (JSON으로 시도) - try { - data = await response.json(); - } catch { - const text = await response.text(); - data = { text, contentType }; - } + // 텍스트 응답인 경우 포맷팅 + if (typeof data === "string") { + data = { text: data, contentType }; } res.status(200).json({ success: true, data, }); - } catch (error) { + } catch (error: any) { + const status = error.response?.status || 500; + const message = error.response?.statusText || error.message; + + logger.error("외부 API 호출 오류:", { + message, + status, + data: error.response?.data, + }); + res.status(500).json({ success: false, message: "외부 API 호출 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" - ? (error as Error).message + ? message : "외부 API 호출 오류", }); } diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 668c07ae..0599a409 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -460,6 +460,105 @@ export class ExternalRestApiConnectionService { } } + /** + * 인증 헤더 생성 + */ + static async getAuthHeaders( + authType: AuthType, + authConfig: any, + companyCode?: string + ): Promise> { + const headers: Record = {}; + + if (authType === "db-token") { + const cfg = authConfig || {}; + const { + dbTableName, + dbValueColumn, + dbWhereColumn, + dbWhereValue, + dbHeaderName, + dbHeaderTemplate, + } = cfg; + + if (!dbTableName || !dbValueColumn) { + throw new Error("DB 토큰 설정이 올바르지 않습니다."); + } + + if (!companyCode) { + throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다."); + } + + const hasWhereColumn = !!dbWhereColumn; + const hasWhereValue = + dbWhereValue !== undefined && + dbWhereValue !== null && + dbWhereValue !== ""; + + // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함 + if (hasWhereColumn !== hasWhereValue) { + throw new Error( + "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다." + ); + } + + // 식별자 검증 (간단한 화이트리스트) + const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + if ( + !identifierRegex.test(dbTableName) || + !identifierRegex.test(dbValueColumn) || + (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string)) + ) { + throw new Error( + "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다." + ); + } + + let sql = ` + SELECT ${dbValueColumn} AS token_value + FROM ${dbTableName} + WHERE company_code = $1 + `; + + const params: any[] = [companyCode]; + + if (hasWhereColumn && hasWhereValue) { + sql += ` AND ${dbWhereColumn} = $2`; + params.push(dbWhereValue); + } + + sql += ` + ORDER BY updated_date DESC + LIMIT 1 + `; + + const tokenResult: QueryResult = await pool.query(sql, params); + + if (tokenResult.rowCount === 0) { + throw new Error("DB에서 토큰을 찾을 수 없습니다."); + } + + const tokenValue = tokenResult.rows[0]["token_value"]; + const headerName = dbHeaderName || "Authorization"; + const template = dbHeaderTemplate || "Bearer {{value}}"; + + headers[headerName] = template.replace("{{value}}", tokenValue); + } else if (authType === "bearer" && authConfig?.token) { + headers["Authorization"] = `Bearer ${authConfig.token}`; + } else if (authType === "basic" && authConfig) { + const credentials = Buffer.from( + `${authConfig.username}:${authConfig.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if (authType === "api-key" && authConfig) { + if (authConfig.keyLocation === "header") { + headers[authConfig.keyName] = authConfig.keyValue; + } + } + + return headers; + } + /** * REST API 연결 테스트 (테스트 요청 데이터 기반) */ @@ -471,99 +570,15 @@ export class ExternalRestApiConnectionService { try { // 헤더 구성 - const headers = { ...testRequest.headers }; + let headers = { ...testRequest.headers }; - // 인증 헤더 추가 - if (testRequest.auth_type === "db-token") { - const cfg = testRequest.auth_config || {}; - const { - dbTableName, - dbValueColumn, - dbWhereColumn, - dbWhereValue, - dbHeaderName, - dbHeaderTemplate, - } = cfg; - - if (!dbTableName || !dbValueColumn) { - throw new Error("DB 토큰 설정이 올바르지 않습니다."); - } - - if (!userCompanyCode) { - throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다."); - } - - const hasWhereColumn = !!dbWhereColumn; - const hasWhereValue = - dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== ""; - - // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함 - if (hasWhereColumn !== hasWhereValue) { - throw new Error( - "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다." - ); - } - - // 식별자 검증 (간단한 화이트리스트) - const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - if ( - !identifierRegex.test(dbTableName) || - !identifierRegex.test(dbValueColumn) || - (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string)) - ) { - throw new Error( - "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다." - ); - } - - let sql = ` - SELECT ${dbValueColumn} AS token_value - FROM ${dbTableName} - WHERE company_code = $1 - `; - - const params: any[] = [userCompanyCode]; - - if (hasWhereColumn && hasWhereValue) { - sql += ` AND ${dbWhereColumn} = $2`; - params.push(dbWhereValue); - } - - sql += ` - ORDER BY updated_date DESC - LIMIT 1 - `; - - const tokenResult: QueryResult = await pool.query(sql, params); - - if (tokenResult.rowCount === 0) { - throw new Error("DB에서 토큰을 찾을 수 없습니다."); - } - - const tokenValue = tokenResult.rows[0]["token_value"]; - const headerName = dbHeaderName || "Authorization"; - const template = dbHeaderTemplate || "Bearer {{value}}"; - - headers[headerName] = template.replace("{{value}}", tokenValue); - } else if ( - testRequest.auth_type === "bearer" && - testRequest.auth_config?.token - ) { - headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`; - } else if (testRequest.auth_type === "basic" && testRequest.auth_config) { - const credentials = Buffer.from( - `${testRequest.auth_config.username}:${testRequest.auth_config.password}` - ).toString("base64"); - headers["Authorization"] = `Basic ${credentials}`; - } else if ( - testRequest.auth_type === "api-key" && - testRequest.auth_config - ) { - if (testRequest.auth_config.keyLocation === "header") { - headers[testRequest.auth_config.keyName] = - testRequest.auth_config.keyValue; - } - } + // 인증 헤더 생성 및 병합 + const authHeaders = await this.getAuthHeaders( + testRequest.auth_type, + testRequest.auth_config, + userCompanyCode + ); + headers = { ...headers, ...authHeaders }; // URL 구성 let url = testRequest.base_url; diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 5c516491..86da8fe7 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [apiConnections, setApiConnections] = useState([]); - const [selectedConnectionId, setSelectedConnectionId] = useState(""); + const [selectedConnectionId, setSelectedConnectionId] = useState(dataSource.externalConnectionId || ""); const [availableColumns, setAvailableColumns] = useState([]); // API 테스트 후 발견된 컬럼 목록 const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보 const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개) @@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M loadApiConnections(); }, []); + // dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트 + useEffect(() => { + if (dataSource.externalConnectionId) { + setSelectedConnectionId(dataSource.externalConnectionId); + } + }, [dataSource.externalConnectionId]); + // 외부 커넥션 선택 핸들러 const handleConnectionSelect = async (connectionId: string) => { setSelectedConnectionId(connectionId); @@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const updates: Partial = { endpoint: fullEndpoint, + externalConnectionId: connectionId, // 외부 연결 ID 저장 }; const headers: KeyValuePair[] = []; const queryParams: KeyValuePair[] = []; + // 기본 메서드/바디가 있으면 적용 + if (connection.default_method) { + updates.method = connection.default_method as ChartDataSource["method"]; + } + if (connection.default_body) { + updates.body = connection.default_body; + } + // 기본 헤더가 있으면 적용 if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { Object.entries(connection.default_headers).forEach(([key, value]) => { @@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M } }); + const bodyPayload = + dataSource.body && dataSource.body.trim().length > 0 + ? dataSource.body + : undefined; + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M method: dataSource.method || "GET", headers, queryParams, + body: bodyPayload, + externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달 }), }); @@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M

+ {/* HTTP 메서드 */} +
+ + +
+ + {/* Request Body (POST/PUT/PATCH 일 때만) */} + {(dataSource.method === "POST" || + dataSource.method === "PUT" || + dataSource.method === "PATCH") && ( +
+ +