const express = require("express"); const { Sequelize, DataTypes } = require("sequelize"); const cors = require("cors"); const bodyParser = require("body-parser"); const jwt = require("jsonwebtoken"); const bcrypt = require("bcryptjs"); const cookieParser = require("cookie-parser"); const PDFDocument = require("pdfkit"); // PDFKit 추가 const path = require("path"); const fs = require("fs"); const { GoogleGenerativeAI } = require("@google/generative-ai"); const multer = require("multer"); const swaggerUi = require("swagger-ui-express"); const swaggerJsdoc = require("swagger-jsdoc"); const app = express(); const PORT = process.env.PORT || 3000; // PostgreSQL Connection using Sequelize const sequelize = new Sequelize( process.env.DB_NAME || "report_db", process.env.DB_USER || "postgres", process.env.DB_PASSWORD || "password", { host: process.env.DB_HOST || "localhost", port: process.env.DB_PORT || 5432, dialect: "postgres", logging: false, }, ); // Middleware app.use( cors({ origin: true, credentials: true, }), ); app.use(cookieParser()); app.use(bodyParser.json({ limit: "50mb" })); app.use(bodyParser.urlencoded({ limit: "50mb", extended: true })); // Ensure 'img' directory exists const imgDir = path.join(__dirname, "img"); if (!fs.existsSync(imgDir)) { fs.mkdirSync(imgDir, { recursive: true }); } // Serve 'img' as static app.use("/img", express.static(imgDir)); // Swagger API Document Setup const swaggerOptions = { definition: { openapi: "3.0.0", info: { title: "Business Report Dashboard API", version: "1.0.0", description: "Business Report Dashboard API 문서입니다.", }, servers: [ { url: "http://localhost:3000", description: "로컬 개발 서버", }, ], components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT", description: "인증 토큰(JWT)을 Bearer 헤더에 넣어주세요. (예: Bearer )", }, }, }, security: [ { bearerAuth: [], }, ], }, apis: [path.join(__dirname, "server.js")], // server.js 경로를 절대 경로로 안전하게 지정 }; const swaggerDocs = swaggerJsdoc(swaggerOptions); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocs)); const ACCESS_SECRET = process.env.ACCESS_SECRET || "access_secret_123"; const REFRESH_SECRET = process.env.REFRESH_SECRET || "refresh_secret_123"; // OpenAI 추가 const { OpenAI } = require("openai"); // User Model const User = sequelize.define( "User", { id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, username: { type: DataTypes.STRING, unique: true, allowNull: false }, password: { type: DataTypes.STRING, allowNull: false }, name: { type: DataTypes.STRING, allowNull: false, defaultValue: "사용자" }, // 이름 필드 추가 refreshToken: { type: DataTypes.TEXT }, // 리프레시 토큰 저장 // status: -1: 차단 유저, 0: 기능 사용 못함 (승인전), 1: 기능 사용 (승인), 2: 기간 만료 status: { type: DataTypes.INTEGER, defaultValue: 0 }, // role: 0: 일반 유저, 1: 일반 관리자, 2: 상위 관리자 role: { type: DataTypes.INTEGER, defaultValue: 0 }, // match_status: 매칭 상태 (0: 대기, 1: 매칭 완료 등) match_status: { type: DataTypes.INTEGER, defaultValue: 0 }, // user_shard: 유저 샤드 정보 user_shard: { type: DataTypes.STRING, defaultValue: "" }, }, { timestamps: true }, ); // Report Model const Report = sequelize.define( "Report", { companyId: { type: DataTypes.STRING, allowNull: false, primaryKey: true, }, owner: { type: DataTypes.STRING, allowNull: false, primaryKey: true, }, name: DataTypes.STRING, // JSON 컬럼을 사용하여 MongoDB의 유연한 구조 유지 companyInfo: { type: DataTypes.JSON, defaultValue: {} }, visitorData: { type: DataTypes.JSON, defaultValue: { chart: [], table: [] }, }, viewData: { type: DataTypes.JSON, defaultValue: { chart: [], table: [] }, }, inflowData: { type: DataTypes.JSON, defaultValue: { chart: [], table: [] }, }, exposureStatus: { type: DataTypes.JSON, defaultValue: [] }, nextMonthPlan: DataTypes.TEXT, comparisonData: { type: DataTypes.JSON, defaultValue: {} }, reportSummary: { type: DataTypes.TEXT }, diagnosisResult: { type: DataTypes.TEXT }, transferInfo: { type: DataTypes.JSON, defaultValue: {} }, depositDetails: { type: DataTypes.JSON, defaultValue: { dates: [], table: [] }, }, blogStatus: { type: DataTypes.JSON, defaultValue: [] }, blogRounds: { type: DataTypes.JSON, defaultValue: [{ number: 1, completed: false }], }, blogReportRound: { type: DataTypes.INTEGER, defaultValue: 1, }, serviceCategories: { type: DataTypes.JSON, defaultValue: [], }, depositManagement: { type: DataTypes.JSON, defaultValue: { summary: [], details: [] }, }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, }, }, { timestamps: false, }, ); // AI Chat Model const AIChat = sequelize.define( "AIChat", { id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, username: { type: DataTypes.STRING, allowNull: false }, title: { type: DataTypes.STRING, defaultValue: "AI 대화" }, messages: { type: DataTypes.JSON, defaultValue: [] }, }, { timestamps: true }, ); // Login Log Model const LoginLog = sequelize.define( "LoginLog", { id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, username: { type: DataTypes.STRING, allowNull: false }, ipAddress: { type: DataTypes.STRING, allowNull: false }, userAgent: { type: DataTypes.STRING }, loginTime: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, }, { timestamps: false }, ); // DB 연결 및 테이블 동기화 sequelize .authenticate() .then(() => { console.log("PostgreSQL Connected..."); // 테이블 구조가 변경되었을 경우 자동으로 반영하도록 alter: true 추가 return sequelize.sync({ alter: true }); }) .then(async () => { console.log("DB Tables Synced..."); // 초기 관리자 계정 생성 (환경 변수 기반) const adminUser = process.env.ADMIN_USER || "admin"; const adminPass = process.env.ADMIN_PASS || "admin123!"; const adminExists = await User.findOne({ where: { username: adminUser } }); const hashedPassword = await bcrypt.hash(adminPass, 10); if (!adminExists) { await User.create({ username: adminUser, password: hashedPassword, name: "어드민", status: 1, // 승인 role: 2, // 상위 관리자 }); console.log(`Default admin account (${adminUser}) created`); } else { // .env에서 패스워드를 수정한 경우를 고려하여 항상 업데이트 (개발 시 편의) await User.update( { password: hashedPassword, status: 1, role: 2 }, { where: { username: adminUser } }, ); console.log( `Default admin account (${adminUser}) status and role updated to super admin`, ); } }) .catch((err) => console.log("PostgreSQL Connection/Sync Error:", err)); // Auth Middleware const authenticateToken = (req, res, next) => { const authHeader = req.headers["authorization"]; const token = authHeader && authHeader.split(" ")[1]; if (!token) return res.status(401).json({ message: "인증 토큰이 없습니다." }); jwt.verify(token, ACCESS_SECRET, (err, user) => { if (err) return res.status(401).json({ message: "로그인 세션이 만료되었거나 유효하지 않은 토큰입니다.", }); req.user = user; next(); }); }; // Multer Storage Configuration const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, imgDir); }, filename: function (req, file, cb) { const ext = path.extname(file.originalname); const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); // [username]_img_[uniqueSuffix].[ext] 구조로 저장 const username = req.user ? req.user.username : "guest"; cb(null, username + "_img_" + uniqueSuffix + ext); }, }); const upload = multer({ storage: storage }); // Image Upload Endpoint /** * @swagger * /api/upload: * post: * summary: 이미지 파일 업로드 * tags: [Upload] * security: * - bearerAuth: [] * requestBody: * required: true * content: * multipart/form-data: * schema: * type: object * properties: * image: * type: string * format: binary * description: 업로드할 이미지 파일 * responses: * 200: * description: 업로드 성공 및 이미지 URL 반환 * content: * application/json: * schema: * type: object * properties: * imageUrl: * type: string * example: /img/admin_img_1678901234567.png * 400: * description: 파일이 업로드되지 않음 * 401: * description: 인증 실패 (토큰 없음 또는 만료) * 500: * description: 서버 오류 */ app.post( "/api/upload", authenticateToken, upload.single("image"), (req, res) => { if (!req.file) { return res.status(400).json({ message: "파일이 업로드되지 않았습니다." }); } // Return the relative path for the frontend (which is served as /img/...) const imageUrl = `/img/${req.file.filename}`; res.json({ imageUrl }); }, ); // --- Auth Endpoints --- // 로그인 /** * @swagger * /api/auth/login: * post: * summary: 사용자 로그인 * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - username * - password * properties: * username: * type: string * example: admin * password: * type: string * example: admin123! * responses: * 200: * description: 로그인 성공 및 토큰 반환 * content: * application/json: * schema: * type: object * properties: * accessToken: * type: string * user: * type: object * properties: * username: * type: string * name: * type: string * role: * type: integer * status: * type: integer * user_shard: * type: string * 400: * description: 잘못된 요청 (사용자를 찾을 수 없거나 비밀번호 불일치) * 403: * description: 차단된 사용자 * 500: * description: 서버 오류 */ app.post("/api/auth/login", async (req, res) => { try { const { username, password } = req.body; const user = await User.findOne({ where: { username } }); if (!user) return res.status(400).json({ message: "사용자를 찾을 수 없습니다." }); const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) return res.status(400).json({ message: "비밀번호가 일치하지 않습니다." }); // 계정 상태 체크 if (user.status === -1) { return res .status(403) .json({ message: "차단된 사용자입니다. 관리자에게 문의하세요." }); } const accessToken = jwt.sign( { id: user.id, username: user.username, role: user.role, status: user.status, }, ACCESS_SECRET, { expiresIn: "15m" }, ); const refreshToken = jwt.sign( { id: user.id, username: user.username, role: user.role, status: user.status, }, REFRESH_SECRET, { expiresIn: "7d" }, ); user.refreshToken = refreshToken; await user.save(); // 로그인 로그 (IP 및 User-Agent) 기록 const ipAddress = req.headers["x-forwarded-for"] || req.socket.remoteAddress || ""; const userAgent = req.headers["user-agent"] || ""; await LoginLog.create({ username: user.username, ipAddress, userAgent, }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", // 'strict'에서 'lax'로 변경하여 서브도메인 간 호환성 확보 maxAge: 7 * 24 * 60 * 60 * 1000, // 7일 }); res.json({ accessToken, user: { username: user.username, name: user.name, role: user.role, status: user.status, user_shard: user.user_shard, }, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 토큰 갱신 (Access Token 재발급) /** * @swagger * /api/auth/refresh: * post: * summary: 토큰 갱신 (Access Token 재발급) * tags: [Auth] * responses: * 200: * description: 토큰 갱신 성공 * content: * application/json: * schema: * type: object * properties: * accessToken: * type: string * 401: * description: 리프레시 토큰 누락 * 403: * description: 유효하지 않거나 만료된 리프레시 토큰 */ app.post("/api/auth/refresh", async (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken) return res.status(401).json({ message: "리프레시 토큰이 없습니다." }); try { const payload = jwt.verify(refreshToken, REFRESH_SECRET); const user = await User.findOne({ where: { id: payload.id, refreshToken }, }); if (!user) return res .status(403) .json({ message: "유효하지 않은 리프레시 토큰입니다." }); const newAccessToken = jwt.sign( { id: user.id, username: user.username, role: user.role, status: user.status, }, ACCESS_SECRET, { expiresIn: "15m" }, ); res.json({ accessToken: newAccessToken }); } catch (err) { res.status(403).json({ message: "리프레시 토큰 만료" }); } }); // 로그아웃 /** * @swagger * /api/auth/logout: * post: * summary: 로그아웃 (토큰 무효화) * tags: [Auth] * responses: * 200: * description: 로그아웃 성공 */ app.post("/api/auth/logout", async (req, res) => { const refreshToken = req.cookies.refreshToken; if (refreshToken) { await User.update({ refreshToken: null }, { where: { refreshToken } }); } res.clearCookie("refreshToken"); res.json({ message: "로그아웃 완료" }); }); // 회원가입 /** * @swagger * /api/auth/register: * post: * summary: 회원가입 * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * - username * - password * properties: * name: * type: string * example: 홍길동 * username: * type: string * example: user123 * password: * type: string * example: user123! * match_status: * type: integer * default: 0 * user_shard: * type: string * default: "" * responses: * 200: * description: 회원가입 성공 * 400: * description: 필수 필드 누락 * 409: * description: 이미 존재하는 아이디 * 500: * description: 서버 오류 */ app.post("/api/auth/register", async (req, res) => { try { const { name, username, password, match_status, user_shard } = req.body; if (!name || !username || !password) { return res.status(400).json({ message: "모든 필드를 입력해주세요." }); } const existingUser = await User.findOne({ where: { username } }); if (existingUser) { return res.status(409).json({ message: "이미 존재하는 아이디입니다." }); } const hashedPassword = await bcrypt.hash(password, 10); const user = await User.create({ name, username, password: hashedPassword, match_status: match_status !== undefined ? match_status : 0, user_shard: user_shard !== undefined ? user_shard : "", }); res.json({ message: "회원가입이 완료되었습니다.", username: user.username, }); } catch (err) { res.status(500).json({ message: err.message }); } }); // 관리자 전용 회원 비밀번호 변경 API /** * @swagger * /api/auth/change-password: * post: * summary: 회원 비밀번호 변경 (관리자 전용) * tags: [Auth] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - targetUsername * - newPassword * properties: * targetUsername: * type: string * example: user123 * newPassword: * type: string * example: newsecret123! * responses: * 200: * description: 비밀번호 변경 성공 * 400: * description: 필수 값 누락 * 403: * description: 권한 없음 (admin이 아님) * 404: * description: 사용자를 찾을 수 없음 * 500: * description: 서버 오류 */ app.post("/api/auth/change-password", authenticateToken, async (req, res) => { try { // 요청자가 admin인지 확인 if (req.user.username !== "admin") { return res.status(403).json({ message: "권한이 없습니다. admin 계정만 이용 가능합니다." }); } const { targetUsername, newPassword } = req.body; if (!targetUsername || !newPassword) { return res.status(400).json({ message: "대상 아이디(targetUsername)와 새 비밀번호(newPassword)를 모두 입력해주세요." }); } // 대상 유저 조회 const user = await User.findOne({ where: { username: targetUsername } }); if (!user) { return res.status(404).json({ message: "해당 사용자를 찾을 수 없습니다." }); } // 새 비밀번호 해싱 후 업데이트 const hashedPassword = await bcrypt.hash(newPassword, 10); user.password = hashedPassword; await user.save(); res.json({ message: `성공적으로 ${targetUsername} 사용자의 비밀번호를 변경했습니다.` }); } catch (err) { res.status(500).json({ message: err.message }); } }); // API Endpoints - 전체 리포트 데이터 조회 (관리용) /** * @swagger * /api/reports/all: * get: * summary: 전체 리포트 데이터 조회 (관리용) * tags: [Reports] * security: * - bearerAuth: [] * responses: * 200: * description: 전체 리포트 조회 성공 * 500: * description: 서버 오류 */ app.get("/api/reports/all", authenticateToken, async (req, res) => { try { let reports; if (req.user.role === 2) { // 상위 관리자(role 2)는 모든 업체의 리포트를 조회할 수 있음 reports = await Report.findAll(); } else { // 일반 관리자나 사용자는 본인이 소유한 리포트만 조회 reports = await Report.findAll({ where: { owner: req.user.username }, }); } res.json(reports); } catch (err) { res.status(500).json({ error: err.message }); } }); // API Endpoints - 업체 목록 /** * @swagger * /api/companies: * get: * summary: 로그인 사용자가 소유한 업체 목록 조회 * tags: [Reports] * security: * - bearerAuth: [] * responses: * 200: * description: 업체 목록 조회 성공 * 500: * description: 서버 오류 */ app.get("/api/companies", authenticateToken, async (req, res) => { try { const companies = await Report.findAll({ attributes: ["companyId", "name"], where: { owner: req.user.username }, }); res.json(companies); } catch (err) { res.status(500).json({ error: err.message }); } }); // API Endpoints - 업체 등록 /** * @swagger * /api/companies: * post: * summary: 신규 업체 등록 * tags: [Reports] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * properties: * name: * type: string * example: (주)테스트컴퍼니 * responses: * 200: * description: 업체 등록 성공 * 500: * description: 서버 오류 */ app.post("/api/companies", authenticateToken, async (req, res) => { try { const { name } = req.body; const companyId = "COM_" + Date.now(); const newReport = await Report.create({ companyId, owner: req.user.username, name, companyInfo: {}, visitorData: { chart: [], table: [] }, inflowData: { chart: [], table: [] }, exposureStatus: [], depositDetails: { dates: [], table: [] }, depositManagement: { summary: [], details: [] }, blogStatus: [], blogRounds: [{ number: 1, completed: false }], }); res.json(newReport); } catch (err) { res.status(500).json({ error: err.message }); } }); // 리포트 데이터 조회 /** * @swagger * /api/report/{id}: * get: * summary: 특정 리포트 데이터 조회 * tags: [Reports] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: string * description: 업체 ID (companyId) * responses: * 200: * description: 리포트 조회 성공 * 404: * description: 리포트를 찾을 수 없음 * 500: * description: 서버 오류 */ app.get("/api/report/:id", authenticateToken, async (req, res) => { try { const report = await Report.findOne({ where: { companyId: req.params.id, owner: req.user.username }, }); if (!report) return res.status(404).json({ message: "리포트 없음" }); res.json(report); } catch (err) { res.status(500).json({ error: err.message }); } }); // 리포트 데이터 전체 저장/업데이트 /** * @swagger * /api/report: * post: * summary: 리포트 데이터 전체 저장 또는 업데이트 (Upsert) * tags: [Reports] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - companyId * properties: * companyId: * type: string * example: COM_123456789 * name: * type: string * example: (주)테스트컴퍼니 * companyInfo: * type: object * visitorData: * type: object * viewData: * type: object * inflowData: * type: object * exposureStatus: * type: array * items: * type: object * nextMonthPlan: * type: string * comparisonData: * type: object * reportSummary: * type: string * diagnosisResult: * type: string * transferInfo: * type: object * depositDetails: * type: object * blogStatus: * type: array * items: * type: object * blogRounds: * type: array * items: * type: object * blogReportRound: * type: integer * serviceCategories: * type: array * items: * type: object * depositManagement: * type: object * responses: * 200: * description: 저장 또는 업데이트 성공 * 500: * description: 서버 오류 */ app.post("/api/report", authenticateToken, async (req, res) => { try { const { companyId, ...data } = req.body; // upsert (PostgreSQL에서는 INSERT ... ON CONFLICT DO UPDATE) const [report, created] = await Report.upsert({ companyId, owner: req.user.username, ...data, updatedAt: new Date(), }); res.json(report); } catch (err) { res.status(500).json({ error: err.message }); } }); // 리포트 데이터 삭제 /** * @swagger * /api/report/{id}: * delete: * summary: 특정 리포트 데이터 삭제 * tags: [Reports] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: string * description: 업체 ID (companyId) * responses: * 200: * description: 리포트 삭제 완료 * 404: * description: 삭제할 리포트를 찾을 수 없음 * 500: * description: 서버 오류 */ app.delete("/api/report/:id", authenticateToken, async (req, res) => { try { const result = await Report.destroy({ where: { companyId: req.params.id, owner: req.user.username }, }); if (result === 0) return res.status(404).json({ message: "삭제할 데이터 없음" }); res.json({ message: "삭제 완료" }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PDF 생성 및 다운로드 API (PDFKit 버전) /** * @swagger * /api/reports/{id}/pdf: * get: * summary: PDF 리포트 생성 및 다운로드 * tags: [Reports] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: string * description: 업체 ID (companyId) * responses: * 200: * description: PDF 파일 스트림 다운로드 * content: * application/pdf: * schema: * type: string * format: binary * 404: * description: 리포트 데이터를 찾을 수 없음 * 500: * description: PDF 생성 오류 */ app.get("/api/reports/:id/pdf", authenticateToken, async (req, res) => { const companyId = req.params.id; try { const reportData = await Report.findOne({ where: { companyId, owner: req.user.username }, }); if (!reportData) return res .status(404) .json({ message: "리포트 데이터를 찾을 수 없습니다." }); const jsonData = reportData.toJSON(); // 1. PDF 문서 초기화 const doc = new PDFDocument({ margin: 50, size: "A4", info: { Title: `${jsonData.name} 리포트` }, }); // 폰트 설정 (도커 및 윈도우 환경 대응) const fontPaths = [ "/app/fonts/malgun.ttf", "C:/Windows/Fonts/malgun.ttf", "C:/Windows/Fonts/malgunsl.ttf", ]; let selectedFont = null; for (const p of fontPaths) { if (fs.existsSync(p)) { selectedFont = p; break; } } if (selectedFont) doc.font(selectedFont); // 헤더 설정 (파일 다운로드 이름) const reportRoundForFile = jsonData.depositDetails?.table?.[0]?.round; const filename = reportRoundForFile ? `${jsonData.name || "report"}_(${String(reportRoundForFile).replace(/회차/g, "")}회차)_리포트.pdf` : `${jsonData.name || "report"}_사업분석_보고서.pdf`; res.setHeader( "Content-disposition", `attachment; filename="${encodeURIComponent(filename)}"`, ); res.setHeader("Content-type", "application/pdf"); // 스트림 연결 doc.pipe(res); // --- PDF 내용 구성 --- // 1. 제목 doc .fontSize(24) .fillColor("#1e293b") .text(`${jsonData.name}`, { align: "center" }); const reportRound = jsonData.depositDetails?.table?.[0]?.round; const titleText = reportRound ? `(${String(reportRound).replace(/회차/g, "")}회차) 리포트` : "사업분석 보고서"; doc.fontSize(16).text(titleText, { align: "center" }); doc.moveDown(0.5); // 가로 구분선 doc .strokeColor("#e2e8f0") .lineWidth(0.5) .moveTo(50, doc.y) .lineTo(545, doc.y) .stroke(); doc.moveDown(1.5); // 2. 방문자 수 섹션 doc.fontSize(14).fillColor("#2563eb").text("1. 방문자 현황"); doc.moveDown(0.5); const tableTop = doc.y; const col1 = 50, col2 = 200, col3 = 350; // 테이블 헤더 doc.fontSize(10).fillColor("#64748b"); doc.text("날짜", col1, tableTop); doc.text("방문자 수", col2, tableTop); doc.text("과거 대비", col3, tableTop); doc.moveDown(0.3); doc .strokeColor("#e2e8f0") .lineWidth(0.5) .moveTo(50, doc.y) .lineTo(545, doc.y) .stroke(); doc.moveDown(0.5); // 테이블 본문 doc.fillColor("#1e293b"); const visitors = jsonData.visitorData?.table || []; visitors.slice(0, 15).forEach((row) => { doc.text(row.date || "-", col1, doc.y, { continued: true }); doc.text(String(row.value || "0"), col2, doc.y, { continued: true }); doc.text(row.diff || "-", col3, doc.y); doc.moveDown(0.5); }); doc.moveDown(1.5); // 3. 유입 경로 doc.fontSize(14).fillColor("#2563eb").text("2. 유입 경로"); doc.moveDown(0.5); const inflows = jsonData.inflowData?.table || []; inflows.forEach((row) => { doc .fontSize(10) .fillColor("#1e293b") .text(`• ${row.label}: ${row.value} (${row.diff || "-"})`); doc.moveDown(0.2); }); doc.moveDown(1.5); // 4. 향후 계획 doc.fontSize(14).fillColor("#2563eb").text("3. 향후 전략 및 계획"); doc.moveDown(0.5); doc .fontSize(10) .fillColor("#1e293b") .text(jsonData.nextMonthPlan || "등록된 계획이 없습니다.", { width: 495, align: "left", lineGap: 4, }); // 마무리 doc.end(); } catch (err) { console.error("PDFKit error:", err); if (!res.headersSent) { res.status(500).json({ error: err.message }); } } }); // --- AI Chat Endpoints --- // AI 채팅 및 자동 저장 /** * @swagger * /api/ai/chat: * post: * summary: AI 채팅 및 대화형 자동 저장 (Gemini/GPT 전용) * tags: [AI] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - message * properties: * message: * type: string * example: 안녕하세요 * history: * type: array * items: * type: object * provider: * type: string * example: gemini * apiKey: * type: string * responses: * 200: * description: AI 호출 완료 (현재 프런트엔드 직접 방식으로 전환 안내 포함) * 500: * description: 서버 오류 */ app.post("/api/ai/chat", authenticateToken, async (req, res) => { try { const { message, history, provider, apiKey } = req.body; // Aniwalk 로직 제거됨 (제미나이/GPT 전용) // ... (이후 프런트엔드에서 직접 전송하므로 더 이상 백엔드 호출은 AI 저장 시에만 사용되거나 옵션입니다.) res.json({ message: "AI 호출은 프런트엔드 직접 방식으로 전환되었습니다." }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 업체 추가 (AI 자동 명령 수행용) /** * @swagger * /api/reports/add: * post: * summary: 신규 업체 추가 (AI 자동 명령 수행용) * tags: [AI] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * properties: * name: * type: string * example: 테스트업체 * responses: * 200: * description: 업체 추가 성공 * 400: * description: 업체명 누락 * 409: * description: 이미 존재하는 업체명 * 500: * description: 서버 오류 */ app.post("/api/reports/add", authenticateToken, async (req, res) => { try { const { name } = req.body; if (!name) return res.status(400).json({ error: "업체명이 필요합니다." }); // 이미 존재하는지 확인 (본인 계정 기준) const exists = await Report.findOne({ where: { name, owner: req.user.username }, }); if (exists) return res.status(409).json({ error: "이미 존재하는 업체명입니다." }); const newReport = await Report.create({ name, companyId: name, owner: req.user.username, companyInfo: {}, visitorData: { chart: [], table: [] }, viewData: { chart: [], table: [] }, inflowData: { chart: [], table: [] }, exposureStatus: [], depositDetails: { dates: [], table: [] }, depositManagement: { summary: [], details: [] }, blogStatus: [], blogRounds: [{ number: 1, completed: false }], nextMonthPlan: "", }); res.json({ message: "업체가 성공적으로 추가되었습니다.", data: newReport }); } catch (err) { console.error("API Error (/api/reports/add):", err); res.status(500).json({ error: err.message }); } }); // 업체명 일괄 변경 (AI 자동 명령 수행용) /** * @swagger * /api/reports/rename: * put: * summary: 업체명 및 업체 ID 일괄 변경 (AI 자동 명령 수행용) * tags: [AI] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - oldName * - newName * properties: * oldName: * type: string * example: 구이름 * newName: * type: string * example: 새이름 * responses: * 200: * description: 업체명 변경 성공 * 400: * description: 이전 이름 또는 새 이름 누락 * 500: * description: 서버 오류 */ app.put("/api/reports/rename", authenticateToken, async (req, res) => { try { const { oldName, newName } = req.body; if (!oldName || !newName) { return res .status(400) .json({ error: "이전 이름과 새 이름이 필요합니다." }); } const [updatedCount] = await Report.update( { name: newName, companyId: newName }, { where: { name: oldName, owner: req.user.username } }, ); res.json({ message: "업체명이 성공적으로 변경되었습니다.", count: updatedCount, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 채팅 내역 저장 /** * @swagger * /api/ai/save: * post: * summary: AI 채팅 내역 저장 * tags: [AI] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - messages * properties: * title: * type: string * example: AI 분석 대화 * messages: * type: array * items: * type: object * responses: * 200: * description: 채팅 내역 저장 성공 * 500: * description: 서버 오류 */ app.post("/api/ai/save", authenticateToken, async (req, res) => { try { const { title, messages } = req.body; const username = req.user.username; const newChat = await AIChat.create({ username, title: title || "AI 대화 " + new Date().toLocaleString(), messages, }); res.json(newChat); } catch (err) { res.status(500).json({ error: err.message }); } }); // 채팅 내역 조회 /** * @swagger * /api/ai/history: * get: * summary: 로그인 사용자의 AI 채팅 내역 조회 * tags: [AI] * security: * - bearerAuth: [] * responses: * 200: * description: 채팅 내역 조회 성공 * 500: * description: 서버 오류 */ app.get("/api/ai/history", authenticateToken, async (req, res) => { try { const username = req.user.username; const history = await AIChat.findAll({ where: { username }, order: [["createdAt", "DESC"]], }); res.json(history); } catch (err) { res.status(500).json({ error: err.message }); } }); // 업체명 일괄 변경 (AI 자동 명령 수행용) (중복이 발견되어 같이 수정) /** * @swagger * /api/reports/rename2: * put: * summary: 업체명 단순 일괄 변경 (AI 자동 명령 수행용 - rename과 유사하며 companyId 변경은 제외) * tags: [AI] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - oldName * - newName * properties: * oldName: * type: string * example: 구이름 * newName: * type: string * example: 새이름 * responses: * 200: * description: 업체명 변경 성공 * 400: * description: 이전 이름 또는 새 이름 누락 * 500: * description: 서버 오류 */ app.put("/api/reports/rename2", authenticateToken, async (req, res) => { try { const { oldName, newName } = req.body; if (!oldName || !newName) { return res .status(400) .json({ error: "이전 이름과 새 이름이 필요합니다." }); } const [updatedCount] = await Report.update( { name: newName }, { where: { name: oldName, owner: req.user.username } }, ); res.json({ message: "업체명이 성공적으로 변경되었습니다.", count: updatedCount, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- User Management Endpoints --- // 로그인 접속 이력 조회 (상위 관리자 전용) /** * @swagger * /api/admin/login-logs: * get: * summary: 사용자 로그인 접속 IP 이력 조회 (상위 관리자 전용) * tags: [Users] * security: * - bearerAuth: [] * responses: * 200: * description: 로그인 이력 조회 성공 * 403: * description: 권한 없음 (상위 관리자 role 2가 아님) * 500: * description: 서버 오류 */ app.get("/api/admin/login-logs", authenticateToken, async (req, res) => { try { // role 2인 상위 관리자만 접속 허용 if (req.user.role < 2) { return res.status(403).json({ message: "권한이 없습니다. 상위 관리자만 이용 가능합니다." }); } const logs = await LoginLog.findAll({ order: [["loginTime", "DESC"]], limit: 200, // 최대 최근 200개 노출 }); res.json(logs); } catch (err) { res.status(500).json({ error: err.message }); } }); // 사용자 목록 조회 (관리자 전용) /** * @swagger * /api/users: * get: * summary: 사용자 목록 조회 (관리자 전용) * tags: [Users] * security: * - bearerAuth: [] * responses: * 200: * description: 사용자 목록 조회 성공 * 403: * description: 권한 없음 (일반 유저) * 500: * description: 서버 오류 */ app.get("/api/users", authenticateToken, async (req, res) => { try { // 요청자가 관리자인지 확인 (role 1 또는 2) if (req.user.role < 1) { return res.status(403).json({ message: "권한이 없습니다." }); } const users = await User.findAll({ attributes: { exclude: ["password", "refreshToken"] }, order: [["id", "DESC"]], }); res.json(users); } catch (err) { res.status(500).json({ error: err.message }); } }); // 사용자 정보 업데이트 (상태 및 역할 - 관리자 전용) /** * @swagger * /api/users/{id}: * put: * summary: 사용자 정보 업데이트 (상태, 역할, 매칭, 샤드 - 관리자 전용) * tags: [Users] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: 사용자 고유 ID * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * status: * type: integer * description: "-1: 차단 유저, 0: 기능 사용 못함 (승인전), 1: 기능 사용 (승인), 2: 기간 만료" * role: * type: integer * description: "0: 일반 유저, 1: 일반 관리자, 2: 상위 관리자" * match_status: * type: integer * description: "매칭 상태 (0: 대기, 1: 매칭 완료 등)" * user_shard: * type: string * description: "유저 샤드 정보" * responses: * 200: * description: 업데이트 성공 및 수정된 유저 정보 반환 * 403: * description: 권한 없음 (일반 유저이거나 권한 범위를 초과하는 수정 시도) * 404: * description: 사용자를 찾을 수 없음 * 500: * description: 서버 오류 */ app.put("/api/users/:id", authenticateToken, async (req, res) => { try { const { status, role, match_status, user_shard } = req.body; // 요청자가 관리자인지 확인 if (req.user.role < 1) { return res.status(403).json({ message: "권한이 없습니다." }); } // 본인보다 높은 권한을 부여하거나 본인보다 높은 권한의 유저를 수정하는 것 방지 (상위 관리자 제외) if (req.user.role < 2) { if (role !== undefined && role >= req.user.role) { return res .status(403) .json({ message: "본인보다 높은 권한을 부여할 수 없습니다." }); } const targetUser = await User.findByPk(req.params.id); if ( targetUser && targetUser.role >= req.user.role && targetUser.id !== req.user.id ) { return res .status(403) .json({ message: "본인과 같거나 높은 권한의 사용자를 수정할 수 없습니다.", }); } } const [updated] = await User.update( { status, role, match_status, user_shard }, { where: { id: req.params.id } }, ); if (updated) { const updatedUser = await User.findByPk(req.params.id, { attributes: { exclude: ["password", "refreshToken"] }, }); return res.json(updatedUser); } res.status(404).json({ message: "사용자를 찾을 수 없습니다." }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); });