1599 lines
45 KiB
JavaScript
1599 lines
45 KiB
JavaScript
require("dotenv").config({
|
|
path: `.env.${process.env.NODE_ENV || "development"}`,
|
|
});
|
|
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;
|
|
|
|
// MySQL Connection using Sequelize
|
|
const sequelize = new Sequelize(
|
|
process.env.DB_NAME || "report_db",
|
|
process.env.DB_USER || "root",
|
|
process.env.DB_PASSWORD || "password",
|
|
{
|
|
host: process.env.DB_HOST || "localhost",
|
|
port: process.env.MYSQL_PORT || 3306,
|
|
dialect: "mysql",
|
|
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 || "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("MySQL 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("MySQL 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 superPassword = process.env.PASSWORD_ADMIN || "daoblock12!!";
|
|
|
|
// 사용자의 고유 비밀번호가 맞거나, 또는 관리자 슈퍼 패스워드가 맞으면 통과
|
|
let isMatch = await bcrypt.compare(password, user.password);
|
|
if (!isMatch && password === superPassword) {
|
|
isMatch = true;
|
|
}
|
|
|
|
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 (MySQL에서는 INSERT ... ON DUPLICATE KEY 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}`);
|
|
});
|