- 봇 스케줄러: 서버 시작 시 자동 초기화, 10초 간격 상태 동기화 - DB 리팩토링: bots 테이블에서 YouTube 컬럼 분리, bot_youtube_config 활용 - 봇별 커스텀 설정: BOT_CUSTOM_CONFIG 상수로 코드 내 관리 - 공개/관리자 일정 목록에 멤버 태그 표시 (5명 이상이면 '프로미스나인') - 일정 목록 글씨 크기 증가 및 UI 개선 - source_name 관리자 일정에 뱃지로 표시 - 봇 시작/정지 토스트에 봇 이름 포함
1729 lines
51 KiB
JavaScript
1729 lines
51 KiB
JavaScript
import express from "express";
|
|
import bcrypt from "bcrypt";
|
|
import jwt from "jsonwebtoken";
|
|
import multer from "multer";
|
|
import sharp from "sharp";
|
|
import {
|
|
S3Client,
|
|
PutObjectCommand,
|
|
DeleteObjectCommand,
|
|
} from "@aws-sdk/client-s3";
|
|
import pool from "../lib/db.js";
|
|
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
|
|
import { startBot, stopBot } from "../services/youtube-scheduler.js";
|
|
|
|
const router = express.Router();
|
|
|
|
// JWT 설정
|
|
const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026";
|
|
const JWT_EXPIRES_IN = "30d";
|
|
|
|
// Multer 설정 (메모리 저장)
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (동영상 지원)
|
|
fileFilter: (req, file, cb) => {
|
|
// 이미지 또는 MP4 비디오 허용
|
|
if (file.mimetype.startsWith("image/") || file.mimetype === "video/mp4") {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error("이미지 또는 MP4 파일만 업로드 가능합니다."), false);
|
|
}
|
|
},
|
|
});
|
|
|
|
// S3 클라이언트 (RustFS)
|
|
const s3Client = new S3Client({
|
|
endpoint: process.env.RUSTFS_ENDPOINT,
|
|
region: "us-east-1",
|
|
credentials: {
|
|
accessKeyId: process.env.RUSTFS_ACCESS_KEY,
|
|
secretAccessKey: process.env.RUSTFS_SECRET_KEY,
|
|
},
|
|
forcePathStyle: true,
|
|
});
|
|
|
|
const BUCKET = process.env.RUSTFS_BUCKET || "fromis-9";
|
|
|
|
// 토큰 검증 미들웨어
|
|
export const authenticateToken = (req, res, next) => {
|
|
const authHeader = req.headers["authorization"];
|
|
const token = authHeader && authHeader.split(" ")[1];
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: "인증이 필요합니다." });
|
|
}
|
|
|
|
jwt.verify(token, JWT_SECRET, (err, user) => {
|
|
if (err) {
|
|
return res.status(403).json({ error: "유효하지 않은 토큰입니다." });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
});
|
|
};
|
|
|
|
// 관리자 로그인
|
|
router.post("/login", async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "아이디와 비밀번호를 입력해주세요." });
|
|
}
|
|
|
|
const [users] = await pool.query(
|
|
"SELECT * FROM admin_users WHERE username = ?",
|
|
[username]
|
|
);
|
|
|
|
if (users.length === 0) {
|
|
return res
|
|
.status(401)
|
|
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
|
}
|
|
|
|
const user = users[0];
|
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
|
|
|
if (!isValidPassword) {
|
|
return res
|
|
.status(401)
|
|
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{ id: user.id, username: user.username },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRES_IN }
|
|
);
|
|
|
|
res.json({
|
|
message: "로그인 성공",
|
|
token,
|
|
user: { id: user.id, username: user.username },
|
|
});
|
|
} catch (error) {
|
|
console.error("로그인 오류:", error);
|
|
res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 토큰 검증 엔드포인트
|
|
router.get("/verify", authenticateToken, (req, res) => {
|
|
res.json({ valid: true, user: req.user });
|
|
});
|
|
|
|
// 초기 관리자 계정 생성
|
|
router.post("/init", async (req, res) => {
|
|
try {
|
|
const [existing] = await pool.query(
|
|
"SELECT COUNT(*) as count FROM admin_users"
|
|
);
|
|
|
|
if (existing[0].count > 0) {
|
|
return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." });
|
|
}
|
|
|
|
const password = "auddnek0403!";
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
|
|
await pool.query(
|
|
"INSERT INTO admin_users (username, password_hash) VALUES (?, ?)",
|
|
["admin", passwordHash]
|
|
);
|
|
|
|
res.json({ message: "관리자 계정이 생성되었습니다." });
|
|
} catch (error) {
|
|
console.error("계정 생성 오류:", error);
|
|
res.status(500).json({ error: "계정 생성 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// ==================== 앨범 관리 API ====================
|
|
|
|
// 앨범 생성
|
|
router.post(
|
|
"/albums",
|
|
authenticateToken,
|
|
upload.single("cover"),
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const data = JSON.parse(req.body.data);
|
|
const {
|
|
title,
|
|
album_type,
|
|
album_type_short,
|
|
release_date,
|
|
folder_name,
|
|
description,
|
|
tracks,
|
|
} = data;
|
|
|
|
// 필수 필드 검증
|
|
if (!title || !album_type || !release_date || !folder_name) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "필수 필드를 모두 입력해주세요." });
|
|
}
|
|
|
|
let coverOriginalUrl = null;
|
|
let coverMediumUrl = null;
|
|
let coverThumbUrl = null;
|
|
|
|
// 커버 이미지 업로드 (3개 해상도)
|
|
if (req.file) {
|
|
// 3가지 크기로 변환 (병렬)
|
|
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
|
sharp(req.file.buffer).webp({ lossless: true }).toBuffer(),
|
|
sharp(req.file.buffer)
|
|
.resize(800, null, { withoutEnlargement: true })
|
|
.webp({ quality: 85 })
|
|
.toBuffer(),
|
|
sharp(req.file.buffer)
|
|
.resize(400, null, { withoutEnlargement: true })
|
|
.webp({ quality: 80 })
|
|
.toBuffer(),
|
|
]);
|
|
|
|
const basePath = `album/${folder_name}/cover`;
|
|
|
|
// S3 업로드 (병렬)
|
|
await Promise.all([
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/original/cover.webp`,
|
|
Body: originalBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/medium_800/cover.webp`,
|
|
Body: mediumBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/thumb_400/cover.webp`,
|
|
Body: thumbBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
]);
|
|
|
|
const publicUrl =
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
|
|
coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`;
|
|
coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`;
|
|
coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`;
|
|
}
|
|
|
|
// 앨범 삽입
|
|
const [albumResult] = await connection.query(
|
|
`INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_original_url, cover_medium_url, cover_thumb_url, description)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
title,
|
|
album_type,
|
|
album_type_short || null,
|
|
release_date,
|
|
folder_name,
|
|
coverOriginalUrl,
|
|
coverMediumUrl,
|
|
coverThumbUrl,
|
|
description || null,
|
|
]
|
|
);
|
|
|
|
const albumId = albumResult.insertId;
|
|
|
|
// 트랙 삽입
|
|
if (tracks && tracks.length > 0) {
|
|
for (const track of tracks) {
|
|
await connection.query(
|
|
`INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
albumId,
|
|
track.track_number,
|
|
track.title,
|
|
track.duration || null,
|
|
track.is_title_track ? 1 : 0,
|
|
track.lyricist || null,
|
|
track.composer || null,
|
|
track.arranger || null,
|
|
track.lyrics || null,
|
|
track.music_video_url || null,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
await connection.commit();
|
|
|
|
res.json({ message: "앨범이 생성되었습니다.", albumId });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("앨범 생성 오류:", error);
|
|
res.status(500).json({ error: "앨범 생성 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 앨범 수정
|
|
router.put(
|
|
"/albums/:id",
|
|
authenticateToken,
|
|
upload.single("cover"),
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const albumId = req.params.id;
|
|
const data = JSON.parse(req.body.data);
|
|
const {
|
|
title,
|
|
album_type,
|
|
album_type_short,
|
|
release_date,
|
|
folder_name,
|
|
description,
|
|
tracks,
|
|
} = data;
|
|
|
|
// 기존 앨범 조회
|
|
const [existingAlbums] = await connection.query(
|
|
"SELECT * FROM albums WHERE id = ?",
|
|
[albumId]
|
|
);
|
|
if (existingAlbums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
let coverOriginalUrl = existingAlbums[0].cover_original_url;
|
|
let coverMediumUrl = existingAlbums[0].cover_medium_url;
|
|
let coverThumbUrl = existingAlbums[0].cover_thumb_url;
|
|
|
|
// 커버 이미지 업로드 (3개 해상도)
|
|
if (req.file) {
|
|
// 3가지 크기로 변환 (병렬)
|
|
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
|
sharp(req.file.buffer).webp({ lossless: true }).toBuffer(),
|
|
sharp(req.file.buffer)
|
|
.resize(800, null, { withoutEnlargement: true })
|
|
.webp({ quality: 85 })
|
|
.toBuffer(),
|
|
sharp(req.file.buffer)
|
|
.resize(400, null, { withoutEnlargement: true })
|
|
.webp({ quality: 80 })
|
|
.toBuffer(),
|
|
]);
|
|
|
|
const basePath = `album/${folder_name}/cover`;
|
|
|
|
// S3 업로드 (병렬)
|
|
await Promise.all([
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/original/cover.webp`,
|
|
Body: originalBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/medium_800/cover.webp`,
|
|
Body: mediumBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/thumb_400/cover.webp`,
|
|
Body: thumbBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
]);
|
|
|
|
const publicUrl =
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
|
|
coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`;
|
|
coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`;
|
|
coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`;
|
|
}
|
|
|
|
// 앨범 업데이트
|
|
await connection.query(
|
|
`UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_original_url = ?, cover_medium_url = ?, cover_thumb_url = ?, description = ?
|
|
WHERE id = ?`,
|
|
[
|
|
title,
|
|
album_type,
|
|
album_type_short || null,
|
|
release_date,
|
|
folder_name,
|
|
coverOriginalUrl,
|
|
coverMediumUrl,
|
|
coverThumbUrl,
|
|
description || null,
|
|
albumId,
|
|
]
|
|
);
|
|
|
|
// 기존 트랙 삭제 후 새 트랙 삽입
|
|
await connection.query("DELETE FROM tracks WHERE album_id = ?", [
|
|
albumId,
|
|
]);
|
|
|
|
if (tracks && tracks.length > 0) {
|
|
for (const track of tracks) {
|
|
await connection.query(
|
|
`INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
albumId,
|
|
track.track_number,
|
|
track.title,
|
|
track.duration || null,
|
|
track.is_title_track ? 1 : 0,
|
|
track.lyricist || null,
|
|
track.composer || null,
|
|
track.arranger || null,
|
|
track.lyrics || null,
|
|
track.music_video_url || null,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
await connection.commit();
|
|
|
|
res.json({ message: "앨범이 수정되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("앨범 수정 오류:", error);
|
|
res.status(500).json({ error: "앨범 수정 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 앨범 삭제
|
|
router.delete("/albums/:id", authenticateToken, async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const albumId = req.params.id;
|
|
|
|
// 기존 앨범 조회
|
|
const [existingAlbums] = await connection.query(
|
|
"SELECT * FROM albums WHERE id = ?",
|
|
[albumId]
|
|
);
|
|
if (existingAlbums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const album = existingAlbums[0];
|
|
|
|
// RustFS에서 커버 이미지 삭제 (3가지 크기)
|
|
if (album.cover_original_url && album.folder_name) {
|
|
const basePath = `album/${album.folder_name}/cover`;
|
|
const sizes = ["original", "medium_800", "thumb_400"];
|
|
for (const size of sizes) {
|
|
try {
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/${size}/cover.webp`,
|
|
})
|
|
);
|
|
} catch (s3Error) {
|
|
console.error(`S3 커버 삭제 오류 (${size}):`, s3Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 트랙 삭제
|
|
await connection.query("DELETE FROM tracks WHERE album_id = ?", [albumId]);
|
|
|
|
// 앨범 삭제
|
|
await connection.query("DELETE FROM albums WHERE id = ?", [albumId]);
|
|
|
|
await connection.commit();
|
|
|
|
res.json({ message: "앨범이 삭제되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("앨범 삭제 오류:", error);
|
|
res.status(500).json({ error: "앨범 삭제 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// 앨범 사진 관리 API
|
|
// ============================================
|
|
|
|
// 앨범 사진 목록 조회
|
|
router.get("/albums/:albumId/photos", async (req, res) => {
|
|
try {
|
|
const { albumId } = req.params;
|
|
|
|
// 앨범 존재 확인
|
|
const [albums] = await pool.query(
|
|
"SELECT folder_name FROM albums WHERE id = ?",
|
|
[albumId]
|
|
);
|
|
if (albums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const folderName = albums[0].folder_name;
|
|
|
|
// 사진 조회 (멤버 정보 포함)
|
|
const [photos] = await pool.query(
|
|
`
|
|
SELECT
|
|
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name,
|
|
p.sort_order, p.width, p.height, p.file_size,
|
|
GROUP_CONCAT(pm.member_id) as member_ids
|
|
FROM album_photos p
|
|
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
|
WHERE p.album_id = ?
|
|
GROUP BY p.id
|
|
ORDER BY p.sort_order ASC
|
|
`,
|
|
[albumId]
|
|
);
|
|
|
|
// 멤버 배열 파싱
|
|
const result = photos.map((photo) => ({
|
|
...photo,
|
|
members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [],
|
|
}));
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error("사진 조회 오류:", error);
|
|
res.status(500).json({ error: "사진 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 앨범 티저 목록 조회
|
|
router.get("/albums/:albumId/teasers", async (req, res) => {
|
|
try {
|
|
const { albumId } = req.params;
|
|
|
|
// 앨범 존재 확인
|
|
const [albums] = await pool.query(
|
|
"SELECT folder_name FROM albums WHERE id = ?",
|
|
[albumId]
|
|
);
|
|
if (albums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
// 티저 조회
|
|
const [teasers] = await pool.query(
|
|
`SELECT id, original_url, medium_url, thumb_url, sort_order, media_type
|
|
FROM album_teasers
|
|
WHERE album_id = ?
|
|
ORDER BY sort_order ASC`,
|
|
[albumId]
|
|
);
|
|
|
|
res.json(teasers);
|
|
} catch (error) {
|
|
console.error("티저 조회 오류:", error);
|
|
res.status(500).json({ error: "티저 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 티저 삭제
|
|
router.delete(
|
|
"/albums/:albumId/teasers/:teaserId",
|
|
authenticateToken,
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const { albumId, teaserId } = req.params;
|
|
|
|
// 티저 정보 조회
|
|
const [teasers] = await connection.query(
|
|
"SELECT t.*, a.folder_name FROM album_teasers t JOIN albums a ON t.album_id = a.id WHERE t.id = ? AND t.album_id = ?",
|
|
[teaserId, albumId]
|
|
);
|
|
|
|
if (teasers.length === 0) {
|
|
return res.status(404).json({ error: "티저를 찾을 수 없습니다." });
|
|
}
|
|
|
|
const teaser = teasers[0];
|
|
const filename = teaser.original_url.split("/").pop();
|
|
const basePath = `album/${teaser.folder_name}/teaser`;
|
|
|
|
// RustFS에서 삭제 (3가지 크기 모두)
|
|
const sizes = ["original", "medium_800", "thumb_400"];
|
|
for (const size of sizes) {
|
|
try {
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/${size}/${filename}`,
|
|
})
|
|
);
|
|
} catch (s3Error) {
|
|
console.error(`S3 삭제 오류 (${size}):`, s3Error);
|
|
}
|
|
}
|
|
|
|
// 티저 삭제
|
|
await connection.query("DELETE FROM album_teasers WHERE id = ?", [
|
|
teaserId,
|
|
]);
|
|
|
|
await connection.commit();
|
|
|
|
res.json({ message: "티저가 삭제되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("티저 삭제 오류:", error);
|
|
res.status(500).json({ error: "티저 삭제 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 사진 업로드 (SSE로 실시간 진행률 전송)
|
|
router.post(
|
|
"/albums/:albumId/photos",
|
|
authenticateToken,
|
|
upload.array("photos", 200),
|
|
async (req, res) => {
|
|
// SSE 헤더 설정
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
|
|
const sendProgress = (current, total, message) => {
|
|
res.write(`data: ${JSON.stringify({ current, total, message })}\n\n`);
|
|
};
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const { albumId } = req.params;
|
|
const metadata = JSON.parse(req.body.metadata || "[]");
|
|
const startNumber = parseInt(req.body.startNumber) || null;
|
|
const photoType = req.body.photoType || "concept"; // 'concept' | 'teaser'
|
|
|
|
// 앨범 정보 조회
|
|
const [albums] = await connection.query(
|
|
"SELECT folder_name FROM albums WHERE id = ?",
|
|
[albumId]
|
|
);
|
|
if (albums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const folderName = albums[0].folder_name;
|
|
|
|
// 시작 번호 결정 (클라이언트 지정 또는 기존 사진 다음 번호)
|
|
let nextOrder;
|
|
if (startNumber && startNumber > 0) {
|
|
nextOrder = startNumber;
|
|
} else {
|
|
const [existingPhotos] = await connection.query(
|
|
"SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?",
|
|
[albumId]
|
|
);
|
|
nextOrder = (existingPhotos[0].maxOrder || 0) + 1;
|
|
}
|
|
|
|
const uploadedPhotos = [];
|
|
const totalFiles = req.files.length;
|
|
|
|
for (let i = 0; i < req.files.length; i++) {
|
|
const file = req.files[i];
|
|
const meta = metadata[i] || {};
|
|
const orderNum = String(nextOrder + i).padStart(2, "0");
|
|
const isVideo = file.mimetype === "video/mp4";
|
|
const extension = isVideo ? "mp4" : "webp";
|
|
const filename = `${orderNum}.${extension}`;
|
|
|
|
// 진행률 전송
|
|
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
|
|
|
|
let originalUrl, mediumUrl, thumbUrl;
|
|
let originalBuffer, originalMeta;
|
|
|
|
// 컨셉 포토: photo/, 티저: teaser/
|
|
const subFolder = photoType === "teaser" ? "teaser" : "photo";
|
|
const basePath = `album/${folderName}/${subFolder}`;
|
|
|
|
if (isVideo) {
|
|
// ===== 비디오 파일 처리 (티저 전용) =====
|
|
// 원본 MP4만 업로드 (리사이즈 없음)
|
|
await s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/original/${filename}`,
|
|
Body: file.buffer,
|
|
ContentType: "video/mp4",
|
|
})
|
|
);
|
|
|
|
originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
|
|
mediumUrl = originalUrl; // 비디오는 원본만 사용
|
|
thumbUrl = originalUrl;
|
|
} else {
|
|
// ===== 이미지 파일 처리 =====
|
|
// Sharp로 이미지 처리 (병렬)
|
|
const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([
|
|
sharp(file.buffer).webp({ lossless: true }).toBuffer(),
|
|
sharp(file.buffer)
|
|
.resize(800, null, { withoutEnlargement: true })
|
|
.webp({ quality: 85 })
|
|
.toBuffer(),
|
|
sharp(file.buffer)
|
|
.resize(400, null, { withoutEnlargement: true })
|
|
.webp({ quality: 80 })
|
|
.toBuffer(),
|
|
]);
|
|
|
|
originalBuffer = origBuf;
|
|
originalMeta = await sharp(originalBuffer).metadata();
|
|
|
|
// RustFS 업로드 (병렬)
|
|
await Promise.all([
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/original/${filename}`,
|
|
Body: originalBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/medium_800/${filename}`,
|
|
Body: medium800Buffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/thumb_400/${filename}`,
|
|
Body: thumb400Buffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
),
|
|
]);
|
|
|
|
originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
|
|
mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`;
|
|
thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`;
|
|
}
|
|
|
|
let photoId;
|
|
|
|
// DB 저장 - 티저와 컨셉 포토 분기
|
|
if (photoType === "teaser") {
|
|
// 티저 이미지/비디오 → album_teasers 테이블
|
|
const mediaType = isVideo ? "video" : "image";
|
|
const [result] = await connection.query(
|
|
`INSERT INTO album_teasers
|
|
(album_id, original_url, medium_url, thumb_url, sort_order, media_type)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
albumId,
|
|
originalUrl,
|
|
mediumUrl,
|
|
thumbUrl,
|
|
nextOrder + i,
|
|
mediaType,
|
|
]
|
|
);
|
|
photoId = result.insertId;
|
|
} else {
|
|
// 컨셉 포토 → album_photos 테이블 (이미지만)
|
|
const [result] = await connection.query(
|
|
`INSERT INTO album_photos
|
|
(album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
albumId,
|
|
originalUrl,
|
|
mediumUrl,
|
|
thumbUrl,
|
|
meta.groupType || "group",
|
|
meta.conceptName || null,
|
|
nextOrder + i,
|
|
originalMeta.width,
|
|
originalMeta.height,
|
|
originalBuffer.length,
|
|
]
|
|
);
|
|
photoId = result.insertId;
|
|
|
|
// 멤버 태깅 저장 (컨셉 포토만)
|
|
if (meta.members && meta.members.length > 0) {
|
|
for (const memberId of meta.members) {
|
|
await connection.query(
|
|
"INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)",
|
|
[photoId, memberId]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
uploadedPhotos.push({
|
|
id: photoId,
|
|
original_url: originalUrl,
|
|
medium_url: mediumUrl,
|
|
thumb_url: thumbUrl,
|
|
filename,
|
|
media_type: isVideo ? "video" : "image",
|
|
});
|
|
}
|
|
|
|
await connection.commit();
|
|
|
|
// 완료 이벤트 전송
|
|
res.write(
|
|
`data: ${JSON.stringify({
|
|
done: true,
|
|
message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`,
|
|
photos: uploadedPhotos,
|
|
})}\n\n`
|
|
);
|
|
res.end();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("사진 업로드 오류:", error);
|
|
res.write(
|
|
`data: ${JSON.stringify({
|
|
error: "사진 업로드 중 오류가 발생했습니다.",
|
|
})}\n\n`
|
|
);
|
|
res.end();
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 사진 삭제
|
|
router.delete(
|
|
"/albums/:albumId/photos/:photoId",
|
|
authenticateToken,
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const { albumId, photoId } = req.params;
|
|
|
|
// 사진 정보 조회
|
|
const [photos] = await connection.query(
|
|
"SELECT p.*, a.folder_name FROM album_photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND p.album_id = ?",
|
|
[photoId, albumId]
|
|
);
|
|
|
|
if (photos.length === 0) {
|
|
return res.status(404).json({ error: "사진을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const photo = photos[0];
|
|
const filename = photo.original_url.split("/").pop();
|
|
const basePath = `album/${photo.folder_name}/photo`;
|
|
|
|
// RustFS에서 삭제 (3가지 크기 모두)
|
|
const sizes = ["original", "medium_800", "thumb_400"];
|
|
for (const size of sizes) {
|
|
try {
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/${size}/${filename}`,
|
|
})
|
|
);
|
|
} catch (s3Error) {
|
|
console.error(`S3 삭제 오류 (${size}):`, s3Error);
|
|
}
|
|
}
|
|
|
|
// 멤버 태깅 삭제
|
|
await connection.query(
|
|
"DELETE FROM album_photo_members WHERE photo_id = ?",
|
|
[photoId]
|
|
);
|
|
|
|
// 사진 삭제
|
|
await connection.query("DELETE FROM album_photos WHERE id = ?", [
|
|
photoId,
|
|
]);
|
|
|
|
await connection.commit();
|
|
|
|
res.json({ message: "사진이 삭제되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("사진 삭제 오류:", error);
|
|
res.status(500).json({ error: "사진 삭제 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// ==================== 멤버 관리 API ====================
|
|
|
|
// 멤버 상세 조회 (이름으로)
|
|
router.get("/members/:name", authenticateToken, async (req, res) => {
|
|
try {
|
|
const memberName = decodeURIComponent(req.params.name);
|
|
const [members] = await pool.query("SELECT * FROM members WHERE name = ?", [
|
|
memberName,
|
|
]);
|
|
|
|
if (members.length === 0) {
|
|
return res.status(404).json({ error: "멤버를 찾을 수 없습니다." });
|
|
}
|
|
|
|
res.json(members[0]);
|
|
} catch (error) {
|
|
console.error("멤버 조회 오류:", error);
|
|
res.status(500).json({ error: "멤버 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 멤버 수정 (이름으로)
|
|
router.put(
|
|
"/members/:name",
|
|
authenticateToken,
|
|
upload.single("image"),
|
|
async (req, res) => {
|
|
try {
|
|
const memberName = decodeURIComponent(req.params.name);
|
|
const { name, birth_date, position, instagram, is_former } = req.body;
|
|
|
|
// 기존 멤버 확인
|
|
const [existing] = await pool.query(
|
|
"SELECT * FROM members WHERE name = ?",
|
|
[memberName]
|
|
);
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "멤버를 찾을 수 없습니다." });
|
|
}
|
|
|
|
const memberId = existing[0].id;
|
|
|
|
let imageUrl = existing[0].image_url;
|
|
|
|
// 새 이미지 업로드
|
|
if (req.file) {
|
|
const webpBuffer = await sharp(req.file.buffer)
|
|
.webp({ quality: 90 })
|
|
.toBuffer();
|
|
|
|
const key = `member/${memberId}/profile.webp`;
|
|
|
|
await s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
Body: webpBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
);
|
|
|
|
const publicUrl =
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
|
|
imageUrl = `${publicUrl}/${BUCKET}/${key}`;
|
|
}
|
|
|
|
// 멤버 업데이트
|
|
await pool.query(
|
|
`UPDATE members SET
|
|
name = ?,
|
|
birth_date = ?,
|
|
position = ?,
|
|
instagram = ?,
|
|
is_former = ?,
|
|
image_url = ?
|
|
WHERE id = ?`,
|
|
[
|
|
name,
|
|
birth_date || null,
|
|
position || null,
|
|
instagram || null,
|
|
is_former === "true" || is_former === true ? 1 : 0,
|
|
imageUrl,
|
|
memberId,
|
|
]
|
|
);
|
|
|
|
res.json({ message: "멤버 정보가 수정되었습니다." });
|
|
} catch (error) {
|
|
console.error("멤버 수정 오류:", error);
|
|
res.status(500).json({ error: "멤버 수정 중 오류가 발생했습니다." });
|
|
}
|
|
}
|
|
);
|
|
|
|
// ==================== 일정 카테고리 관리 API ====================
|
|
|
|
// 카테고리 목록 조회 (인증 불필요 - 폼에서 사용)
|
|
router.get("/schedule-categories", async (req, res) => {
|
|
try {
|
|
const [categories] = await pool.query(
|
|
"SELECT * FROM schedule_categories ORDER BY sort_order ASC"
|
|
);
|
|
res.json(categories);
|
|
} catch (error) {
|
|
console.error("카테고리 조회 오류:", error);
|
|
res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 카테고리 생성
|
|
router.post("/schedule-categories", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { name, color } = req.body;
|
|
|
|
if (!name || !color) {
|
|
return res.status(400).json({ error: "이름과 색상은 필수입니다." });
|
|
}
|
|
|
|
// 현재 최대 sort_order 조회
|
|
const [maxOrder] = await pool.query(
|
|
"SELECT MAX(sort_order) as maxOrder FROM schedule_categories"
|
|
);
|
|
const nextOrder = (maxOrder[0].maxOrder || 0) + 1;
|
|
|
|
const [result] = await pool.query(
|
|
"INSERT INTO schedule_categories (name, color, sort_order) VALUES (?, ?, ?)",
|
|
[name, color, nextOrder]
|
|
);
|
|
|
|
res.json({
|
|
message: "카테고리가 생성되었습니다.",
|
|
id: result.insertId,
|
|
sort_order: nextOrder,
|
|
});
|
|
} catch (error) {
|
|
console.error("카테고리 생성 오류:", error);
|
|
res.status(500).json({ error: "카테고리 생성 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 카테고리 수정
|
|
router.put("/schedule-categories/:id", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { name, color, sort_order } = req.body;
|
|
|
|
const [existing] = await pool.query(
|
|
"SELECT * FROM schedule_categories WHERE id = ?",
|
|
[id]
|
|
);
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." });
|
|
}
|
|
|
|
await pool.query(
|
|
"UPDATE schedule_categories SET name = ?, color = ?, sort_order = ? WHERE id = ?",
|
|
[
|
|
name || existing[0].name,
|
|
color || existing[0].color,
|
|
sort_order !== undefined ? sort_order : existing[0].sort_order,
|
|
id,
|
|
]
|
|
);
|
|
|
|
res.json({ message: "카테고리가 수정되었습니다." });
|
|
} catch (error) {
|
|
console.error("카테고리 수정 오류:", error);
|
|
res.status(500).json({ error: "카테고리 수정 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 카테고리 삭제
|
|
router.delete(
|
|
"/schedule-categories/:id",
|
|
authenticateToken,
|
|
async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const [existing] = await pool.query(
|
|
"SELECT * FROM schedule_categories WHERE id = ?",
|
|
[id]
|
|
);
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." });
|
|
}
|
|
|
|
// TODO: 해당 카테고리를 사용하는 일정이 있는지 확인
|
|
await pool.query("DELETE FROM schedule_categories WHERE id = ?", [id]);
|
|
|
|
res.json({ message: "카테고리가 삭제되었습니다." });
|
|
} catch (error) {
|
|
console.error("카테고리 삭제 오류:", error);
|
|
res.status(500).json({ error: "카테고리 삭제 중 오류가 발생했습니다." });
|
|
}
|
|
}
|
|
);
|
|
|
|
// 카테고리 순서 일괄 업데이트
|
|
router.put(
|
|
"/schedule-categories-order",
|
|
authenticateToken,
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const { orders } = req.body; // [{ id: 1, sort_order: 1 }, { id: 2, sort_order: 2 }, ...]
|
|
|
|
if (!orders || !Array.isArray(orders)) {
|
|
return res.status(400).json({ error: "순서 데이터가 필요합니다." });
|
|
}
|
|
|
|
for (const item of orders) {
|
|
await connection.query(
|
|
"UPDATE schedule_categories SET sort_order = ? WHERE id = ?",
|
|
[item.sort_order, item.id]
|
|
);
|
|
}
|
|
|
|
await connection.commit();
|
|
res.json({ message: "순서가 업데이트되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("순서 업데이트 오류:", error);
|
|
res.status(500).json({ error: "순서 업데이트 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
// ==================== 일정 관리 API ====================
|
|
|
|
// 일정 목록 조회
|
|
router.get("/schedules", async (req, res) => {
|
|
try {
|
|
const { year, month, search } = req.query;
|
|
|
|
let whereConditions = [];
|
|
let params = [];
|
|
|
|
// 검색어가 있으면 전체 일정에서 검색 (년/월 필터 무시)
|
|
if (search && search.trim()) {
|
|
const searchTerm = `%${search.trim()}%`;
|
|
whereConditions.push("(s.title LIKE ? OR s.description LIKE ?)");
|
|
params.push(searchTerm, searchTerm);
|
|
} else {
|
|
// 년/월 필터링 (검색이 아닐 때만)
|
|
if (year && month) {
|
|
whereConditions.push("YEAR(s.date) = ? AND MONTH(s.date) = ?");
|
|
params.push(parseInt(year), parseInt(month));
|
|
} else if (year) {
|
|
whereConditions.push("YEAR(s.date) = ?");
|
|
params.push(parseInt(year));
|
|
}
|
|
}
|
|
|
|
const whereClause =
|
|
whereConditions.length > 0
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|
: "";
|
|
|
|
const [schedules] = await pool.query(
|
|
`SELECT
|
|
s.id, s.title, s.date, s.time, s.end_date, s.end_time,
|
|
s.category_id, s.description, s.source_url, s.source_name,
|
|
s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng,
|
|
s.created_at,
|
|
c.name as category_name, c.color as category_color
|
|
FROM schedules s
|
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
|
${whereClause}
|
|
ORDER BY s.date ASC, s.time ASC`,
|
|
params
|
|
);
|
|
|
|
// 각 일정의 이미지와 멤버 조회
|
|
const schedulesWithDetails = await Promise.all(
|
|
schedules.map(async (schedule) => {
|
|
const [images] = await pool.query(
|
|
"SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC",
|
|
[schedule.id]
|
|
);
|
|
const [members] = await pool.query(
|
|
`SELECT m.id, m.name FROM schedule_members sm
|
|
JOIN members m ON sm.member_id = m.id
|
|
WHERE sm.schedule_id = ?`,
|
|
[schedule.id]
|
|
);
|
|
return { ...schedule, images, members };
|
|
})
|
|
);
|
|
|
|
res.json(schedulesWithDetails);
|
|
} catch (error) {
|
|
console.error("일정 조회 오류:", error);
|
|
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 일정 생성
|
|
router.post(
|
|
"/schedules",
|
|
authenticateToken,
|
|
upload.array("images", 20),
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const data = JSON.parse(req.body.data);
|
|
const {
|
|
title,
|
|
date,
|
|
time,
|
|
endDate,
|
|
endTime,
|
|
isRange,
|
|
category,
|
|
description,
|
|
url,
|
|
sourceName,
|
|
members,
|
|
locationName,
|
|
locationAddress,
|
|
locationDetail,
|
|
locationLat,
|
|
locationLng,
|
|
} = data;
|
|
|
|
// 필수 필드 검증
|
|
if (!title || !date) {
|
|
return res.status(400).json({ error: "제목과 날짜를 입력해주세요." });
|
|
}
|
|
|
|
// 일정 삽입
|
|
const [scheduleResult] = await connection.query(
|
|
`INSERT INTO schedules
|
|
(title, date, time, end_date, end_time, category_id, description, source_url, source_name,
|
|
location_name, location_address, location_detail, location_lat, location_lng)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
title,
|
|
date,
|
|
time || null,
|
|
isRange && endDate ? endDate : null,
|
|
isRange && endTime ? endTime : null,
|
|
category || null,
|
|
description || null,
|
|
url || null,
|
|
sourceName || null,
|
|
locationName || null,
|
|
locationAddress || null,
|
|
locationDetail || null,
|
|
locationLat || null,
|
|
locationLng || null,
|
|
]
|
|
);
|
|
|
|
const scheduleId = scheduleResult.insertId;
|
|
|
|
// 멤버 연결 처리 (schedule_members 테이블)
|
|
if (members && members.length > 0) {
|
|
const memberValues = members.map((memberId) => [scheduleId, memberId]);
|
|
await connection.query(
|
|
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
|
|
[memberValues]
|
|
);
|
|
}
|
|
|
|
// 이미지 업로드 처리
|
|
if (req.files && req.files.length > 0) {
|
|
const publicUrl =
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
|
|
const basePath = `schedule/${scheduleId}`;
|
|
|
|
for (let i = 0; i < req.files.length; i++) {
|
|
const file = req.files[i];
|
|
const orderNum = String(i + 1).padStart(2, "0");
|
|
const filename = `${orderNum}.webp`;
|
|
|
|
// WebP 변환 (원본만)
|
|
const imageBuffer = await sharp(file.buffer)
|
|
.webp({ quality: 90 })
|
|
.toBuffer();
|
|
|
|
// RustFS 업로드 (원본만)
|
|
await s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `${basePath}/${filename}`,
|
|
Body: imageBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
);
|
|
|
|
const imageUrl = `${publicUrl}/${BUCKET}/${basePath}/${filename}`;
|
|
|
|
// DB 저장
|
|
await connection.query(
|
|
`INSERT INTO schedule_images (schedule_id, image_url, sort_order)
|
|
VALUES (?, ?, ?)`,
|
|
[scheduleId, imageUrl, i + 1]
|
|
);
|
|
}
|
|
}
|
|
|
|
await connection.commit();
|
|
|
|
res.json({ message: "일정이 생성되었습니다.", scheduleId });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("일정 생성 오류:", error);
|
|
res.status(500).json({ error: "일정 생성 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 카카오 장소 검색 프록시 (CORS 우회)
|
|
router.get("/kakao/places", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { query } = req.query;
|
|
|
|
if (!query) {
|
|
return res.status(400).json({ error: "검색어를 입력해주세요." });
|
|
}
|
|
|
|
const response = await fetch(
|
|
`https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent(
|
|
query
|
|
)}`,
|
|
{
|
|
headers: {
|
|
Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Kakao API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
res.json(data);
|
|
} catch (error) {
|
|
console.error("카카오 장소 검색 오류:", error);
|
|
res.status(500).json({ error: "장소 검색 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 일정 단일 조회
|
|
router.get("/schedules/:id", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// 일정 기본 정보 조회
|
|
const [schedules] = await pool.query(
|
|
`SELECT s.*, sc.name as category_name, sc.color as category_color
|
|
FROM schedules s
|
|
LEFT JOIN schedule_categories sc ON s.category_id = sc.id
|
|
WHERE s.id = ?`,
|
|
[id]
|
|
);
|
|
|
|
if (schedules.length === 0) {
|
|
return res.status(404).json({ error: "일정을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const schedule = schedules[0];
|
|
|
|
// 이미지 조회
|
|
const [images] = await pool.query(
|
|
"SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC",
|
|
[id]
|
|
);
|
|
|
|
// 멤버 조회
|
|
const [members] = await pool.query(
|
|
`SELECT m.id, m.name, m.image_url
|
|
FROM schedule_members sm
|
|
JOIN members m ON sm.member_id = m.id
|
|
WHERE sm.schedule_id = ?`,
|
|
[id]
|
|
);
|
|
|
|
res.json({ ...schedule, images, members });
|
|
} catch (error) {
|
|
console.error("일정 조회 오류:", error);
|
|
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 일정 수정
|
|
router.put(
|
|
"/schedules/:id",
|
|
authenticateToken,
|
|
upload.array("images", 20),
|
|
async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const { id } = req.params;
|
|
const data = JSON.parse(req.body.data || "{}");
|
|
const {
|
|
title,
|
|
date,
|
|
time,
|
|
endDate,
|
|
endTime,
|
|
isRange,
|
|
category,
|
|
description,
|
|
url,
|
|
sourceName,
|
|
members,
|
|
locationName,
|
|
locationAddress,
|
|
locationDetail,
|
|
locationLat,
|
|
locationLng,
|
|
existingImages, // 유지할 기존 이미지 ID 배열
|
|
} = data;
|
|
|
|
// 필수 필드 검증
|
|
if (!title || !date) {
|
|
return res.status(400).json({ error: "제목과 날짜를 입력해주세요." });
|
|
}
|
|
|
|
// 일정 업데이트
|
|
await connection.query(
|
|
`UPDATE schedules SET
|
|
title = ?,
|
|
date = ?,
|
|
time = ?,
|
|
end_date = ?,
|
|
end_time = ?,
|
|
category_id = ?,
|
|
description = ?,
|
|
source_url = ?,
|
|
source_name = ?,
|
|
location_name = ?,
|
|
location_address = ?,
|
|
location_detail = ?,
|
|
location_lat = ?,
|
|
location_lng = ?
|
|
WHERE id = ?`,
|
|
[
|
|
title,
|
|
date,
|
|
time || null,
|
|
isRange && endDate ? endDate : null,
|
|
isRange && endTime ? endTime : null,
|
|
category || null,
|
|
description || null,
|
|
url || null,
|
|
sourceName || null,
|
|
locationName || null,
|
|
locationAddress || null,
|
|
locationDetail || null,
|
|
locationLat || null,
|
|
locationLng || null,
|
|
id,
|
|
]
|
|
);
|
|
|
|
// 멤버 업데이트 (기존 삭제 후 새로 추가)
|
|
await connection.query(
|
|
"DELETE FROM schedule_members WHERE schedule_id = ?",
|
|
[id]
|
|
);
|
|
if (members && members.length > 0) {
|
|
const memberValues = members.map((memberId) => [id, memberId]);
|
|
await connection.query(
|
|
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
|
|
[memberValues]
|
|
);
|
|
}
|
|
|
|
// 삭제할 이미지 처리 (existingImages에 없는 이미지 삭제)
|
|
const existingImageIds = existingImages || [];
|
|
if (existingImageIds.length > 0) {
|
|
// 삭제할 이미지 조회
|
|
const [imagesToDelete] = await connection.query(
|
|
`SELECT id, image_url FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`,
|
|
[id, existingImageIds]
|
|
);
|
|
|
|
// S3에서 이미지 삭제
|
|
for (const img of imagesToDelete) {
|
|
try {
|
|
const key = img.image_url.replace(
|
|
`${
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT
|
|
}/${process.env.RUSTFS_BUCKET}/`,
|
|
""
|
|
);
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: process.env.RUSTFS_BUCKET,
|
|
Key: key,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error("이미지 삭제 오류:", err);
|
|
}
|
|
}
|
|
|
|
// DB에서 삭제
|
|
await connection.query(
|
|
`DELETE FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`,
|
|
[id, existingImageIds]
|
|
);
|
|
} else {
|
|
// 기존 이미지 모두 삭제
|
|
const [allImages] = await connection.query(
|
|
`SELECT id, image_url FROM schedule_images WHERE schedule_id = ?`,
|
|
[id]
|
|
);
|
|
|
|
for (const img of allImages) {
|
|
try {
|
|
const key = img.image_url.replace(
|
|
`${
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT
|
|
}/${process.env.RUSTFS_BUCKET}/`,
|
|
""
|
|
);
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: process.env.RUSTFS_BUCKET,
|
|
Key: key,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error("이미지 삭제 오류:", err);
|
|
}
|
|
}
|
|
|
|
await connection.query(
|
|
`DELETE FROM schedule_images WHERE schedule_id = ?`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
// 새 이미지 업로드
|
|
if (req.files && req.files.length > 0) {
|
|
const publicUrl =
|
|
process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT;
|
|
const basePath = `schedule/${id}`;
|
|
|
|
// 현재 최대 sort_order 조회
|
|
const [maxOrder] = await connection.query(
|
|
"SELECT COALESCE(MAX(sort_order), 0) as max_order FROM schedule_images WHERE schedule_id = ?",
|
|
[id]
|
|
);
|
|
let currentOrder = maxOrder[0].max_order;
|
|
|
|
for (let i = 0; i < req.files.length; i++) {
|
|
const file = req.files[i];
|
|
currentOrder++;
|
|
const orderNum = String(currentOrder).padStart(2, "0");
|
|
const filename = `${orderNum}_${Date.now()}.webp`;
|
|
|
|
const imageBuffer = await sharp(file.buffer)
|
|
.webp({ quality: 90 })
|
|
.toBuffer();
|
|
|
|
await s3Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: process.env.RUSTFS_BUCKET,
|
|
Key: `${basePath}/${filename}`,
|
|
Body: imageBuffer,
|
|
ContentType: "image/webp",
|
|
})
|
|
);
|
|
|
|
const imageUrl = `${publicUrl}/${process.env.RUSTFS_BUCKET}/${basePath}/${filename}`;
|
|
|
|
await connection.query(
|
|
"INSERT INTO schedule_images (schedule_id, image_url, sort_order) VALUES (?, ?, ?)",
|
|
[id, imageUrl, currentOrder]
|
|
);
|
|
}
|
|
}
|
|
|
|
await connection.commit();
|
|
res.json({ message: "일정이 수정되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("일정 수정 오류:", error);
|
|
res.status(500).json({ error: "일정 수정 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
);
|
|
|
|
// 일정 삭제
|
|
router.delete("/schedules/:id", authenticateToken, async (req, res) => {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const { id } = req.params;
|
|
|
|
// 이미지 조회
|
|
const [images] = await connection.query(
|
|
"SELECT image_url FROM schedule_images WHERE schedule_id = ?",
|
|
[id]
|
|
);
|
|
|
|
// S3에서 이미지 삭제
|
|
for (const img of images) {
|
|
try {
|
|
const key = img.image_url.replace(
|
|
`${process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT}/${
|
|
process.env.RUSTFS_BUCKET
|
|
}/`,
|
|
""
|
|
);
|
|
await s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: process.env.RUSTFS_BUCKET,
|
|
Key: key,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error("이미지 삭제 오류:", err);
|
|
}
|
|
}
|
|
|
|
// 일정 삭제 (CASCADE로 schedule_images, schedule_members도 자동 삭제)
|
|
await connection.query("DELETE FROM schedules WHERE id = ?", [id]);
|
|
|
|
await connection.commit();
|
|
res.json({ message: "일정이 삭제되었습니다." });
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
console.error("일정 삭제 오류:", error);
|
|
res.status(500).json({ error: "일정 삭제 중 오류가 발생했습니다." });
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
});
|
|
|
|
// =====================================================
|
|
// YouTube 봇 API
|
|
// =====================================================
|
|
|
|
// 봇 목록 조회
|
|
router.get("/bots", authenticateToken, async (req, res) => {
|
|
try {
|
|
const [bots] = await pool.query(`
|
|
SELECT b.*, c.channel_id, c.rss_url, c.channel_name
|
|
FROM bots b
|
|
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
|
ORDER BY b.id ASC
|
|
`);
|
|
res.json(bots);
|
|
} catch (error) {
|
|
console.error("봇 목록 조회 오류:", error);
|
|
res.status(500).json({ error: "봇 목록 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 봇 시작
|
|
router.post("/bots/:id/start", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
await startBot(id);
|
|
res.json({ message: "봇이 시작되었습니다." });
|
|
} catch (error) {
|
|
console.error("봇 시작 오류:", error);
|
|
res
|
|
.status(500)
|
|
.json({ error: error.message || "봇 시작 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 봇 정지
|
|
router.post("/bots/:id/stop", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
await stopBot(id);
|
|
res.json({ message: "봇이 정지되었습니다." });
|
|
} catch (error) {
|
|
console.error("봇 정지 오류:", error);
|
|
res
|
|
.status(500)
|
|
.json({ error: error.message || "봇 정지 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 전체 동기화 (초기화)
|
|
router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await syncAllVideos(id);
|
|
res.json({
|
|
message: `${result.addedCount}개 일정이 추가되었습니다.`,
|
|
addedCount: result.addedCount,
|
|
total: result.total,
|
|
});
|
|
} catch (error) {
|
|
console.error("전체 동기화 오류:", error);
|
|
res
|
|
.status(500)
|
|
.json({ error: error.message || "전체 동기화 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
export default router;
|