import express from "express"; import cookieParser from "cookie-parser"; import { createServer as createViteServer } from "vite"; import path from "path"; import fs from "fs/promises"; import { fileURLToPath } from "url"; import { searchPipeline } from "./src/services/searchPipeline.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // snap_data 폴더 절대 경로 — q-pids 상위 폴더 기준 // 환경변수 SNAP_DATA_DIR로 오버라이드 가능 const SNAP_DATA_DIR = process.env.SNAP_DATA_DIR || path.resolve(__dirname, "..", "snap_data"); // ───────────────────────────────────────────────────────────── // 인증 서버 설정 // ───────────────────────────────────────────────────────────── const AUTH_API_BASE = process.env.AUTH_API_BASE || "http://10.150.6.47:8090"; const COOKIE_NAME = "snap_auth"; const COOKIE_MAX_AGE = 12 * 60 * 60 * 1000; // 12시간 (ms) async function startServer() { const app = express(); const PORT = Number(process.env.PORT || 8888); // ─── 미들웨어 ─── app.use(cookieParser()); app.use(express.json({ limit: "2mb" })); app.use(express.urlencoded({ extended: true })); // ───────────────────────────────────────────────────────────── // 인증 라우트 // ───────────────────────────────────────────────────────────── // POST /api/auth/login // React가 { username, password } JSON을 보내면 // 인증 서버에 form-urlencoded로 변환하여 전달 app.post("/api/auth/login", async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ detail: "사번과 비밀번호를 입력하세요" }); } try { const params = new URLSearchParams({ username, password }); const response = await fetch(`${AUTH_API_BASE}/login`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), }); const data = await response.json(); if (!response.ok) { return res.status(response.status).json(data); } // 토큰 추출 → HttpOnly 쿠키 설정 const { token, ...userInfo } = data; if (!token) { return res.status(500).json({ detail: "토큰이 응답에 없습니다" }); } res.cookie(COOKIE_NAME, token, { httpOnly: true, sameSite: "lax", secure: false, // 사내망 HTTP면 false, HTTPS면 true maxAge: COOKIE_MAX_AGE, path: "/", }); // 토큰 제외한 사용자 정보만 React에 반환 return res.json(userInfo); } catch (err) { console.error("[auth/login] 인증 서버 호출 실패:", err); return res.status(502).json({ detail: "인증 서버 연결 실패" }); } }); // POST /api/auth/logout // 쿠키 삭제 + 인증 서버에 블랙리스트 등록 요청 app.post("/api/auth/logout", async (req, res) => { const token = req.cookies?.[COOKIE_NAME]; // 쿠키 무조건 삭제 (서버 호출 성공 여부 무관) res.clearCookie(COOKIE_NAME, { path: "/" }); if (token) { try { await fetch(`${AUTH_API_BASE}/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); } catch (err) { // 인증 서버 호출 실패해도 쿠키는 이미 삭제됨 — 무시 console.warn("[auth/logout] 인증 서버 블랙리스트 등록 실패:", err); } } return res.json({ message: "로그아웃 되었습니다" }); }); // ───────────────────────────────────── // QP Backend Pipeline Endpoint — POST 기반 (filters 전달용) // ───────────────────────────────────── app.post("/api/search", async (req, res) => { const { query, region, product, topK } = req.body || {}; try { const result = await searchPipeline.execute({ query, region, product, topK, }); res.json(result); } catch (error: any) { console.error("Pipeline Error:", error); res .status(500) .json({ error: error?.message || "Internal Pipeline Error" }); } }); // 하위 호환: 기존 GET /api/search?q=... 도 유지 app.get("/api/search", async (req, res) => { const { q, region, product } = req.query; try { const result = await searchPipeline.execute({ query: q as string, region: region as string, product: product as string, }); res.json(result); } catch (error: any) { console.error("Pipeline Error:", error); res .status(500) .json({ error: error?.message || "Internal Pipeline Error" }); } }); // ───────────────────────────────────── // 문서 상세 조회 — FastAPI /search/doc-detail 프록시 // 카드 클릭 시 모달에서 LLM 종합 답변(vlm_answer)을 받아 표시 // ───────────────────────────────────── app.post("/api/doc-detail", async (req, res) => { const { query, docId, pages } = req.body || {}; if (!query || !docId || !Array.isArray(pages)) { res.status(400).json({ error: "query, docId, pages(array) are required", }); return; } try { const result = await searchPipeline.fetchDocDetail({ query, docId, pages, }); res.json(result); } catch (error: any) { console.error("DocDetail Error:", error); res .status(500) .json({ error: error?.message || "Internal DocDetail Error" }); } }); // ───────────────────────────────────── // 문서의 전체 페이지 수 조회 — snap_data/_*/ 폴더의 *.jpg 파일들을 카운트 // 파일명 패턴: _<문서명>_.jpg // ───────────────────────────────────── app.get("/api/doc-pages", async (req, res) => { const docId = String(req.query.docId || "").trim(); if (!docId) { res.status(400).json({ error: "docId required" }); return; } try { // docId가 폴더명 prefix와 동일 (README 기준). 폴더 직접 조회. const folderPath = path.join(SNAP_DATA_DIR, docId); const files = await fs.readdir(folderPath); const pageNums: number[] = []; for (const f of files) { if (!f.toLowerCase().endsWith(".jpg")) continue; // 파일 끝의 _.jpg 추출 const m = f.match(/_(\d+)\.jpg$/i); if (m) pageNums.push(Number(m[1])); } pageNums.sort((a, b) => a - b); const totalPages = pageNums.length > 0 ? pageNums[pageNums.length - 1] : 0; res.json({ totalPages, availablePages: pageNums }); } catch (error: any) { if (error?.code === "ENOENT") { res.json({ totalPages: 0, availablePages: [] }); return; } console.error("doc-pages error:", error); res.status(500).json({ error: error?.message || "Internal Error" }); } }); // ───────────────────────────────────── // 페이지 스크린샷 정적 서빙 — `/snap_data//` // ───────────────────────────────────── app.use( "/snap_data", express.static(SNAP_DATA_DIR, { fallthrough: true, maxAge: "1h", }), ); console.log(`[snap_data] serving from ${SNAP_DATA_DIR}`); // ───────────────────────────────────── // Vite middleware (dev) / static (prod) // ───────────────────────────────────── if (process.env.NODE_ENV !== "production") { const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa", }); app.use(vite.middlewares); } else { const distPath = path.join(process.cwd(), "dist"); app.use(express.static(distPath)); app.get("*", (req, res) => { res.sendFile(path.join(distPath, "index.html")); }); } app.listen(PORT, "0.0.0.0", () => { console.log(`Server running on http://localhost:${PORT}`); console.log(`[auth] Auth server: ${AUTH_API_BASE}`); }); } startServer();