Files
ttipo 126c0169aa
Deploy / deploy (push) Successful in 35s
DB 볼륨을 /data/postgres 바인드 마운트로 변경
- named volume 대신 호스트 경로 바인드 마운트 사용 (DB_DATA_DIR, 기본 /data/postgres)
- backend: DB 연결/동기화 실패 시 프로세스 종료 - restart: always가 DB 준비까지 재시도하도록 fail-fast 적용

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:56:44 +09:00

1600 lines
45 KiB
JavaScript

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,
{
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 <token>)",
},
},
},
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;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
if (!ACCESS_SECRET || !REFRESH_SECRET) {
console.error("ACCESS_SECRET / REFRESH_SECRET 환경변수가 필요합니다.");
process.exit(1);
}
// 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;
const adminPass = process.env.ADMIN_PASS;
if (!adminUser || !adminPass) {
console.log("ADMIN_USER/ADMIN_PASS 미설정 - 관리자 계정 생성을 건너뜁니다.");
return;
}
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.error("PostgreSQL Connection/Sync Error:", err);
process.exit(1);
});
// 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}`);
});