Compare commits

..

No commits in common. "346d6529f2d21194c0f27137458bec8c6e11efe1" and "52332babea0a3dd63ab145bfd099744652c985ea" have entirely different histories.

12 changed files with 9 additions and 333 deletions

1
.env
View file

@ -22,4 +22,3 @@ KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8
# YouTube API # YouTube API
YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91

1
.gitignore vendored
View file

@ -18,4 +18,3 @@ Thumbs.db
# Build # Build
dist/ dist/
build/ build/
meilisearch_data/

View file

@ -12,7 +12,6 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"meilisearch": "^0.55.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
@ -2782,12 +2781,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/meilisearch": {
"version": "0.55.0",
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.55.0.tgz",
"integrity": "sha512-qSMeiezfDgIqciIeYzh5E4pXDZZD7CtHeWDCs43kN3trLgl5FtfmBAIkljL3huFaOx08feYtC8FfIFUpVwq6rg==",
"license": "MIT"
},
"node_modules/merge-descriptors": { "node_modules/merge-descriptors": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",

View file

@ -11,7 +11,6 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"meilisearch": "^0.55.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View file

@ -11,10 +11,6 @@ import {
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js"; import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
import { startBot, stopBot } from "../services/youtube-scheduler.js"; import { startBot, stopBot } from "../services/youtube-scheduler.js";
import {
addOrUpdateSchedule,
deleteSchedule as deleteScheduleFromSearch,
} from "../services/meilisearch.js";
const router = express.Router(); const router = express.Router();
@ -1325,33 +1321,6 @@ router.post(
await connection.commit(); await connection.commit();
// Meilisearch에 동기화
try {
const [categoryInfo] = await pool.query(
"SELECT name, color FROM schedule_categories WHERE id = ?",
[category || null]
);
const [memberInfo] = await pool.query(
"SELECT id, name FROM members WHERE id IN (?)",
[members?.length ? members : [0]]
);
await addOrUpdateSchedule({
id: scheduleId,
title,
description,
date,
time,
category_id: category,
category_name: categoryInfo[0]?.name || "",
category_color: categoryInfo[0]?.color || "",
source_name: sourceName,
source_url: url,
members: memberInfo,
});
} catch (searchError) {
console.error("Meilisearch 동기화 오류:", searchError.message);
}
res.json({ message: "일정이 생성되었습니다.", scheduleId }); res.json({ message: "일정이 생성되었습니다.", scheduleId });
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
@ -1681,14 +1650,6 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
await connection.query("DELETE FROM schedules WHERE id = ?", [id]); await connection.query("DELETE FROM schedules WHERE id = ?", [id]);
await connection.commit(); await connection.commit();
// Meilisearch에서도 삭제
try {
await deleteScheduleFromSearch(id);
} catch (searchError) {
console.error("Meilisearch 삭제 오류:", searchError.message);
}
res.json({ message: "일정이 삭제되었습니다." }); res.json({ message: "일정이 삭제되었습니다." });
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();

View file

@ -1,21 +1,11 @@
import express from "express"; import express from "express";
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { searchSchedules } from "../services/meilisearch.js";
const router = express.Router(); const router = express.Router();
// 공개 일정 목록 조회 (검색 포함) // 공개 일정 목록 조회
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try { try {
const { search } = req.query;
// 검색어가 있으면 Meilisearch 사용
if (search && search.trim()) {
const results = await searchSchedules(search.trim());
return res.json(results);
}
// 검색어 없으면 DB에서 전체 조회
const [schedules] = await pool.query(` const [schedules] = await pool.query(`
SELECT SELECT
s.id, s.id,
@ -90,38 +80,4 @@ router.get("/:id", async (req, res) => {
} }
}); });
// Meilisearch 동기화 API
router.post("/sync-search", async (req, res) => {
try {
const { syncAllSchedules } = await import("../services/meilisearch.js");
// DB에서 모든 일정 조회
const [schedules] = await pool.query(`
SELECT
s.id,
s.title,
s.description,
s.date,
s.time,
s.category_id,
s.source_url,
s.source_name,
c.name as category_name,
c.color as category_color,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ' ') as member_names
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
LEFT JOIN members m ON sm.member_id = m.id
GROUP BY s.id
`);
const count = await syncAllSchedules(schedules);
res.json({ success: true, synced: count });
} catch (error) {
console.error("Meilisearch 동기화 오류:", error);
res.status(500).json({ error: "동기화 중 오류가 발생했습니다." });
}
});
export default router; export default router;

View file

@ -7,7 +7,6 @@ import statsRouter from "./routes/stats.js";
import adminRouter from "./routes/admin.js"; import adminRouter from "./routes/admin.js";
import schedulesRouter from "./routes/schedules.js"; import schedulesRouter from "./routes/schedules.js";
import { initScheduler } from "./services/youtube-scheduler.js"; import { initScheduler } from "./services/youtube-scheduler.js";
import { initMeilisearch } from "./services/meilisearch.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -45,14 +44,6 @@ app.get("*", (req, res) => {
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
// Meilisearch 초기화
try {
await initMeilisearch();
console.log("🔍 Meilisearch 초기화 완료");
} catch (error) {
console.error("Meilisearch 초기화 오류:", error);
}
// YouTube 봇 스케줄러 초기화 // YouTube 봇 스케줄러 초기화
try { try {
await initScheduler(); await initScheduler();

View file

@ -1,163 +0,0 @@
import { MeiliSearch } from "meilisearch";
// Meilisearch 클라이언트 초기화
const client = new MeiliSearch({
host: "http://fromis9-meilisearch:7700",
apiKey: process.env.MEILI_MASTER_KEY,
});
const SCHEDULE_INDEX = "schedules";
/**
* 인덱스 초기화 설정
*/
export async function initMeilisearch() {
try {
// 인덱스 생성 (이미 존재하면 무시)
await client.createIndex(SCHEDULE_INDEX, { primaryKey: "id" });
// 인덱스 설정
const index = client.index(SCHEDULE_INDEX);
// 검색 가능한 필드 설정
await index.updateSearchableAttributes([
"title",
"description",
"member_names",
"category_name",
"source_name",
]);
// 필터링 가능한 필드 설정
await index.updateFilterableAttributes(["category_id", "date"]);
// 정렬 가능한 필드 설정
await index.updateSortableAttributes(["date", "time"]);
// 오타 허용 설정 (typo tolerance)
await index.updateTypoTolerance({
enabled: true,
minWordSizeForTypos: {
oneTypo: 2,
twoTypos: 4,
},
});
console.log("[Meilisearch] 인덱스 초기화 완료");
} catch (error) {
console.error("[Meilisearch] 초기화 오류:", error.message);
}
}
/**
* 일정 문서 추가/업데이트
*/
export async function addOrUpdateSchedule(schedule) {
try {
const index = client.index(SCHEDULE_INDEX);
// 멤버 이름을 문자열로 변환
const memberNames = schedule.members
? schedule.members.map((m) => m.name).join(" ")
: "";
const document = {
id: schedule.id,
title: schedule.title,
description: schedule.description || "",
date: schedule.date,
time: schedule.time || "",
category_id: schedule.category_id,
category_name: schedule.category_name || "",
category_color: schedule.category_color || "",
source_name: schedule.source_name || "",
source_url: schedule.source_url || "",
member_names: memberNames,
};
await index.addDocuments([document]);
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
} catch (error) {
console.error("[Meilisearch] 문서 추가 오류:", error.message);
}
}
/**
* 일정 문서 삭제
*/
export async function deleteSchedule(scheduleId) {
try {
const index = client.index(SCHEDULE_INDEX);
await index.deleteDocument(scheduleId);
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
} catch (error) {
console.error("[Meilisearch] 문서 삭제 오류:", error.message);
}
}
/**
* 일정 검색
*/
export async function searchSchedules(query, options = {}) {
try {
const index = client.index(SCHEDULE_INDEX);
const searchOptions = {
limit: options.limit || 50,
attributesToRetrieve: ["*"],
};
// 카테고리 필터
if (options.categoryId) {
searchOptions.filter = `category_id = ${options.categoryId}`;
}
// 정렬 (기본: 날짜 내림차순)
searchOptions.sort = options.sort || ["date:desc", "time:desc"];
const results = await index.search(query, searchOptions);
return results.hits;
} catch (error) {
console.error("[Meilisearch] 검색 오류:", error.message);
return [];
}
}
/**
* 모든 일정 동기화 (초기 데이터 로드용)
*/
export async function syncAllSchedules(schedules) {
try {
const index = client.index(SCHEDULE_INDEX);
// 기존 문서 모두 삭제
await index.deleteAllDocuments();
// 문서 변환
const documents = schedules.map((schedule) => ({
id: schedule.id,
title: schedule.title,
description: schedule.description || "",
date: schedule.date,
time: schedule.time || "",
category_id: schedule.category_id,
category_name: schedule.category_name || "",
category_color: schedule.category_color || "",
source_name: schedule.source_name || "",
source_url: schedule.source_url || "",
member_names: schedule.member_names || "",
}));
// 일괄 추가
await index.addDocuments(documents);
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
return documents.length;
} catch (error) {
console.error("[Meilisearch] 동기화 오류:", error.message);
return 0;
}
}
export { client };

View file

@ -1,6 +1,5 @@
import Parser from "rss-parser"; import Parser from "rss-parser";
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { addOrUpdateSchedule } from "./meilisearch.js";
// YouTube API 키 // YouTube API 키
const YOUTUBE_API_KEY = const YOUTUBE_API_KEY =
@ -294,33 +293,6 @@ export async function createScheduleFromVideo(
); );
} }
// Meilisearch에 동기화
try {
const [categoryInfo] = await pool.query(
"SELECT name, color FROM schedule_categories WHERE id = ?",
[categoryId]
);
const [memberInfo] = await pool.query(
"SELECT id, name FROM members WHERE id IN (?)",
[memberIds.length > 0 ? [...new Set(memberIds)] : [0]]
);
await addOrUpdateSchedule({
id: scheduleId,
title: video.title,
description: "",
date: video.date,
time: video.time,
category_id: categoryId,
category_name: categoryInfo[0]?.name || "",
category_color: categoryInfo[0]?.color || "",
source_name: sourceName,
source_url: video.videoUrl,
members: memberInfo,
});
} catch (searchError) {
console.error("Meilisearch 동기화 오류:", searchError.message);
}
return scheduleId; return scheduleId;
} catch (error) { } catch (error) {
console.error("일정 생성 오류:", error); console.error("일정 생성 오류:", error);

View file

@ -11,17 +11,6 @@ services:
- db - db
restart: unless-stopped restart: unless-stopped
meilisearch:
image: getmeili/meilisearch:v1.6
container_name: fromis9-meilisearch
environment:
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
volumes:
- ./meilisearch_data:/meili_data
networks:
- app
restart: unless-stopped
networks: networks:
app: app:
external: true external: true

View file

@ -5,23 +5,13 @@ import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft }
function Schedule() { function Schedule() {
const navigate = useNavigate(); const navigate = useNavigate();
// KST (YYYY-MM-DD)
const getTodayKST = () => {
const now = new Date();
const kstOffset = 9 * 60 * 60 * 1000; // 9
const kstDate = new Date(now.getTime() + kstOffset);
return kstDate.toISOString().split('T')[0];
};
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(getTodayKST()); // KST const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); //
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); const [showYearMonthPicker, setShowYearMonthPicker] = useState(false);
const [viewMode, setViewMode] = useState('yearMonth'); const [viewMode, setViewMode] = useState('yearMonth');
const [slideDirection, setSlideDirection] = useState(0); const [slideDirection, setSlideDirection] = useState(0);
const pickerRef = useRef(null); const pickerRef = useRef(null);
// //
const [schedules, setSchedules] = useState([]); const [schedules, setSchedules] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);

View file

@ -10,27 +10,17 @@ import Tooltip from '../../../components/Tooltip';
function AdminSchedule() { function AdminSchedule() {
const navigate = useNavigate(); const navigate = useNavigate();
// KST (YYYY-MM-DD)
const getTodayKST = () => {
const now = new Date();
const kstOffset = 9 * 60 * 60 * 1000;
const kstDate = new Date(now.getTime() + kstOffset);
return kstDate.toISOString().split('T')[0];
};
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState(''); //
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState(''); // ( )
const [isSearchMode, setIsSearchMode] = useState(false); const [isSearchMode, setIsSearchMode] = useState(false); //
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState([]); // (API )
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false); //
const [selectedCategories, setSelectedCategories] = useState([]); const [selectedCategories, setSelectedCategories] = useState([]); // =
const [selectedDate, setSelectedDate] = useState(getTodayKST()); // KST const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [slideDirection, setSlideDirection] = useState(0); const [slideDirection, setSlideDirection] = useState(0);
// (Schedule.jsx ) // (Schedule.jsx )