Вопрос или проблема
Я работаю над приложением для чата, основанным на стеке MERN, где данные чата хранятся в MongoDB. Однако пользователи получаются из внешнего API, который предоставляет числовые идентификаторы SQL. Система входа работает нормально через перенаправление и полезную нагрузку, но у нас возникают проблемы с отправкой и получением сообщений, так как наша система обмена сообщениями ожидает идентификаторы объекта MongoDB, в то время как пользователи имеют только числовые идентификаторы SQL из API.
**Текущая настройка:
**1. Фронтенд: React (вход через перенаправление, получение полезной нагрузки с данными пользователей).
- Бэкэнд: Node.js (Express) с MongoDB для хранения данных чата.
- Пользователи: Получены из внешнего API в виде JSON-ответа (с числовыми идентификаторами SQL).
- Чат: Сообщения хранятся в MongoDB, и система ожидает, что у пользователей будут идентификаторы объектов MongoDB.
**Проблема:
**Нам нужно поддерживать текущую настройку (пользователи получаются из внешнего API), но наша система чата не может отправлять и получать сообщения, так как ожидает идентификаторы объектов MongoDB для пользователей. Мы хотим внести минимальные изменения в существующее приложение MERN, которое изначально было создано для работы с идентификаторами MongoDB.
Мы планируем получать пользователей из внешнего API и сопоставлять их с пользователями MongoDB внутри функции, которая фильтрует их для чата (например, в getUsersForSidebar).
export const login = async (req, res) => {
try {
const { id, fullName, email, role_id, role, handshakeToken } = req.body;
console.log("Попытка входа:", { id, fullName, email, role_id, role });
// Убедитесь, что все необходимые поля предоставлены
if (!id || !fullName || !email || !handshakeToken || !role_id || !role) {
return res.status(400).json({ error: "Неверная полезная нагрузка. Убедитесь, что все необходимые поля предоставлены." });
}
// Проверьте токен взаимодействия
const expectedHandshakeToken = process.env.HANDSHAKE_TOKEN;
if (handshakeToken !== expectedHandshakeToken) {
return res.status(401).json({ error: "Неверный токен взаимодействия." });
}
// Проверьте role_id и role
const parsedRoleId = Number(role_id);
if (isNaN(parsedRoleId) || typeof role !== 'string') {
return res.status(400).json({ error: "Неверный 'role_id' или 'role'. Убедитесь, что они правильно сформатированы." });
}
// Получите данные пользователя из внешнего API для проверки ID и email
const externalApiUrl = process.env.EXTERNAL_API_URL;
const apiResponse = await axios.get(externalApiUrl);
if (!apiResponse.data.status) {
return res.status(400).json({ error: "Не удалось получить пользователей из API" });
}
// Найдите пользователя из API по email
const apiUser = apiResponse.data.data.users.find(user => user.email === email);
if (!apiUser) {
return res.status(404).json({ error: "Пользователь не найден во внешнем API." });
}
// Проверьте, совпадает ли `id` с email из API
if (apiUser.id.toString() !== id.toString()) {
return res.status(400).json({ error: "Предоставленный ID не совпадает с email из внешнего API." });
}
// Проверьте роль в соответствии с validRoles
if (!Object.values(validRoles).some(r => r.id === parsedRoleId && r.name === role)) {
return res.status(400).json({ error: "Неверный 'role_id' или 'role'." });
}
// Создайте или обновите пользователя в базе данных (теперь включает `id`)
const user = await User.findOneAndUpdate(
{ email },
{
id: apiUser.id, // Используйте проверенный `id` из API
fullName,
email,
role_id: parsedRoleId,
role
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
console.log("Пользователь после обновления:", user);
// Сгенерируйте JWT с информацией о пользователе
const token = jwt.sign(
{
userId: user._id,
sqlId: user.id, // Включите SQL id в JWT
email: user.email,
role_id: user.role_id,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Установите токен как cookie
res.cookie("jwt", token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 1000 // 1 час
});
console.log("Вход выполнен успешно для:", user.email);
return res.status(200).json({
message: "Вход выполнен успешно",
user: {
id: user.id, // Верните SQL id в ответе
fullName: user.fullName,
email: user.email,
role_id: user.role_id,
role: user.role
}
});
} catch (error) {
console.error("Ошибка во время входа:", error);
return res.status(500).json({ error: "Внутренняя ошибка сервера. Пожалуйста, проверьте лог сервера для получения подробностей." });
}
};
import axios from 'axios';
import User from "../models/user.model.js";
import Conversation from "../models/conversation.model.js";
import { canInitiateChat, roleHierarchy } from "../utils/rolePermissions.js";
import { validRoles } from "../utils/validRoles.js";
export const getUsersForSidebar = async (req, res) => {
try {
const currentUserId = req.user._id; // Используйте ID вместо email
// Получите текущего пользователя из базы данных
const currentUser = await User.findById(currentUserId);
if (!currentUser) {
return res.status(404).json({ error: "Пользователь не найден" });
}
const currentUserRoleId = currentUser.role_id;
console.log("ID роли текущего пользователя:", currentUserRoleId);
console.log("Имя роли текущего пользователя:", currentUser.role);
// Получите пользователей из внешнего API
const externalApiUrl = process.env.EXTERNAL_API_URL;
const apiResponse = await axios.get(externalApiUrl);
if (!apiResponse.data.status) {
return res.status(400).json({ error: "Не удалось получить пользователей из API" });
}
const allUsers = apiResponse.data.data.users;
// Отфильтруйте пользователей на основе правил инициирования чата
const filteredUsers = allUsers.filter(user =>
user.id.toString() !== currentUser._id.toString() && // Исключите текущего пользователя по ID
canInitiateChat(currentUserRoleId, user.role_id)
);
// Получите существующие беседы
const existingConversations = await Conversation.find({
participants: currentUser._id
})
.populate('participants', '_id role_id name email image_url')
.populate({
path: 'messages',
options: { sort: { createdAt: -1 }, limit: 1 },
select: 'createdAt senderId receiverId isRead'
});
// Извлеките участников из бесед
const conversationParticipants = existingConversations.flatMap(conversation => {
const latestMessage = conversation.messages[0] || null;
return conversation.participants
.filter(participant =>
participant._id.toString() !== currentUser._id.toString() &&
canInitiateChat(currentUserRoleId, participant.role_id)
)
.map(participant => ({
_id: participant._id,
role_id: participant.role_id,
fullName: participant.name,
email: participant.email,
profilePic: participant.image_url,
lastMessageTimestamp: latestMessage ? latestMessage.createdAt : conversation.updatedAt,
unreadMessages: latestMessage && latestMessage.receiverId.toString() === currentUser._id.toString() && !latestMessage.isRead ? 1 : 0
}));
});
// Объедините и удалите дубликаты пользователей
const uniqueUsersMap = new Map();
filteredUsers.forEach(user => {
const roleName = validRoles[user.role.name] ? user.role.name : `Неизвестная роль (${user.role_id})`;
uniqueUsersMap.set(user.id.toString(), {
_id: user.id,
role_id: user.role_id,
fullName: user.name,
email: user.email,
profilePic: user.image_url,
lastMessageTimestamp: null,
unreadMessages: 0,
roleName: roleName
});
});
conversationParticipants.forEach(user => {
const existingUser = uniqueUsersMap.get(user._id.toString());
if (existingUser) {
uniqueUsersMap.set(user._id.toString(), {
...existingUser,
lastMessageTimestamp: user.lastMessageTimestamp,
unreadMessages: user.unreadMessages
});
} else {
uniqueUsersMap.set(user._id.toString(), user);
}
});
const mergedUsers = Array.from(uniqueUsersMap.values());
// Отсортируйте пользователей по lastMessageTimestamp
const sortedUsers = mergedUsers.sort((a, b) => {
const aTimestamp = a.lastMessageTimestamp ? new Date(a.lastMessageTimestamp).getTime() : 0;
const bTimestamp = b.lastMessageTimestamp ? new Date(b.lastMessageTimestamp).getTime() : 0;
return bTimestamp - aTimestamp;
});
// Группируйте пользователей по role_id
const groupedUsers = sortedUsers.reduce((acc, user) => {
const roleName = user.roleName || roleHierarchy[user.role_id] || `Неизвестная роль (${user.role_id})`;
if (!acc[roleName]) {
acc[roleName] = [];
}
acc[roleName].push(user);
return acc;
}, {});
// Отправьте сгруппированных пользователей в ответе
res.status(200).json(groupedUsers);
} catch (error) {
console.error("Ошибка в контроллере getUsersForSidebar", error.message);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
export const sendMessage = async (req, res) => {
try {
const { message } = req.body;
const { id: receiverId } = req.params;
const senderId = req.user._id;
let fileUrl = null;
let originalFileName = null; // Поле для оригинального имени файла
// Проверьте, загружен ли файл (используя Multer для загрузки файлов)
if (req.file) {
const fileBuffer = req.file.buffer; // Получите буфер файла из multer
const cloudinaryResult = await uploadOnCloudinary(fileBuffer); // Загрузите на Cloudinary
if (cloudinaryResult) {
fileUrl = cloudinaryResult; // Сохраните URL Cloudinary
originalFileName = req.file.originalname; // Захватите оригинальное имя файла из Multer
}
}
// Проверьте, существует ли беседа между отправителем и получателем, или создайте новую
let conversation = await Conversation.findOne({
participants: { $all: [senderId, receiverId] },
});
if (!conversation) {
conversation = await Conversation.create({
participants: [senderId, receiverId],
});
}
// Создайте новое сообщение (включает файл, если он присутствует)
const newMessage = new Message({
senderId,
receiverId,
message: message || "", // Текст сообщения
fileUrl, // URL Cloudinary, если файл загружен
originalFileName, // Оригинальное имя файла, если файл загружен
});
if (newMessage) {
conversation.messages.push(newMessage._id);
}
await Promise.all([conversation.save(), newMessage.save()]);
// ФУНКЦИОНАЛЬНОСТЬ SOCKET IO
const receiverSocketId = getReceiverSocketId(receiverId);
if (receiverSocketId) {
// Отправьте сообщение получателю
io.to(receiverSocketId).emit("newMessage", newMessage);
// Излучите событие для обновления боковой панели для всех подключенных клиентов
io.emit("updateSidebar", {
senderId,
receiverId,
message: newMessage,
});
}
// Ответьте отправителю новым сообщением
res.status(201).json(newMessage);
} catch (error) {
console.error("Ошибка в контроллере sendMessage: ", error.message);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
export const getMessages = async (req, res) => {
try {
const { id: userToChatId } = req.params;
const senderId = req.user._id;
const conversation = await Conversation.findOne({
participants: { $all: [senderId, userToChatId] },
}).populate("messages"); // НЕ ССЫЛКА, А ФАКТИЧЕСКИЕ СООБЩЕНИЯ
if (!conversation) return res.status(200).json([]);
const messages = conversation.messages;
res.status(200).json(messages);
} catch (error) {
console.log("Ошибка в контроллере getMessages: ", error.message);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
export const authenticate = async (req, res, next) => {
const token = req.cookies.jwt;
if (!token) {
return res.status(401).json({ error: "Неавторизованный. Токен не предоставлен." });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.userId); // Найдите пользователя по ID вместо email
if (!user) {
return res.status(401).json({ error: "Пользователь не найден." });
}
req.user = {
_id: user._id,
email: user.email,
role_id: user.role_id,
role: user.role
};
next();
} catch (error) {
return res.status(401).json({ error: "Неверный или просроченный токен." });
}
};
// backend\models\conversation.model.js
import mongoose from "mongoose";
const conversationSchema = new mongoose.Schema(
{
participants: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "User",
},
],
messages: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Message",
default: [],
},
],
},
{ timestamps: true }
);
// Добавьте виртуальное поле для расчета времени последнего сообщения
conversationSchema.virtual('lastMessageTimestamp').get(function() {
if (this.messages.length > 0) {
return this.messages[this.messages.length - 1].createdAt; // Время последнего сообщения
}
return null;
});
const Conversation = mongoose.model("Conversation", conversationSchema);
export default Conversation;
import mongoose from 'mongoose';
const messageSchema = new mongoose.Schema(
{
senderId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
receiverId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
message: {
type: String,
default: '',
},
fileUrl: {
type: String,
default: null, // Для хранения URL Cloudinary
},
originalFileName: {
type: String,
default: null, // Для хранения оригинального имени файла из локальной системы пользователя
},
isRead: { // Отследите, было ли сообщение прочитано
type: Boolean,
default: false,
},
},
{ timestamps: true }
);
const Message = mongoose.model('Message', messageSchema);
export default Message;
// backend\routes\message.routes.js
import express from "express";
import { getMessages, sendMessage, downloadFile } from "../controllers/message.controller.js";
import protectRoute from "../middleware/protectRoute.js";
import { upload } from "../middleware/multer.js"; // Импортируйте multer для загрузки файлов
const router = express.Router();
// Получите сообщения для конкретной беседы (защищено)
router.get("/:id", protectRoute, getMessages);
// Отправьте сообщение с необязательными медиа (защищено)
// Используйте middleware загрузки multer для обработки загрузки файлов (один файл за запрос)
router.post("/send/:id", protectRoute, upload.single('file'), sendMessage);
// Маршрут для загрузки файла
router.get('/download/:messageId', downloadFile);
export default router;
import jwt from "jsonwebtoken";
import User from "../models/user.model.js";
const protectRoute = async (req, res, next) => {
try {
// Пропустите аутентификацию для этого конкретного маршрута
if (req.path === '/create-superadmin') {
return next();
}
const token = req.cookies.jwt;
if (!token) {
return res.status(401).json({ error: "Неавторизованный - Токен не предоставлен" });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded) {
return res.status(401).json({ error: "Неавторизованный - Неверный токен" });
}
const user = await User.findById(decoded.userId).select("-password");
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
req.user = user;
next();
} catch (error) {
console.log("Ошибка в middleware protectRoute: ", error.message);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
export default protectRoute;
import { Server } from "socket.io";
import http from "http";
import express from "express";
import Message from "../models/message.model.js";
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: ["http://localhost:3000"],
methods: ["GET", "POST"],
},
});
const userSocketMap = {}; // {userId: socketId}
export const getReceiverSocketId = (receiverId) => {
return userSocketMap[receiverId];
};
io.on("connection", (socket) => {
console.log("пользователь подключен", socket.id);
const userId = socket.handshake.query.userId;
if (userId !== "undefined") userSocketMap[userId] = socket.id;
io.emit("getOnlineUsers", Object.keys(userSocketMap));
socket.on("disconnect", () => {
console.log("пользователь отключен", socket.id);
delete userSocketMap[userId];
io.emit("getOnlineUsers", Object.keys(userSocketMap));
});
socket.on("markMessageAsRead", async ({ conversationId }) => {
try {
// Предположим, conversationId - это идентификатор беседы
// Вам нужно обновить сообщения, где receiverId - текущий пользователь и isRead - false
await Message.updateMany(
{ receiverId: userId, isRead: false }, // Замените conversationId соответствующим условием
{ $set: { isRead: true } }
);
// Уведомите всех клиентов (кроме текущего), что сообщения в этой беседе прочитаны
socket.broadcast.emit("messageRead", { conversationId });
} catch (error) {
console.error("Ошибка при отметке сообщений как прочитанных:", error);
}
});
socket.on("newMessage", async ({ senderId, receiverId, message }) => {
try {
// Создайте новое сообщение и отметьте его как непрочитанное
const newMessage = await Message.create({ senderId, receiverId, message, isRead: false });
// Излучите событие для обновления боковых панелей отправителя и получателя
io.to([senderId, receiverId]).emit("updateSidebar", { senderId, receiverId, message: newMessage });
} catch (error) {
console.error("Ошибка при отправке нового сообщения:", error);
}
});
// Существующие события для изменений статусов запросов ролей
socket.on("requestStatusChange", (changeType) => {
io.emit("requestStatusChange", changeType);
});
});
export { app, io, server };
Я ищу предложения о том, как лучше всего реализовать это сопоставление, сохраняя логику входа и получения пользователей нетронутой, и как структурировать это без угрозы производительности или архитектурных проблем.
В конце концов, получение пользователей и вход должно работать так же, как сейчас, но отправка и получение сообщений также должны работать.
Ответ или решение
Интеграция SQL идентификаторов из внешнего API с чатом на базе MongoDB в MERN-стеке
Ваша задача по интеграции идентификаторов пользователей из SQL в систему обмена сообщениями на основе MongoDB в чат-приложении MERN-стека имеет несколько ключевых этапов. Давайте подробно рассмотрим, как можно эффективно решить эту проблему, минимизируя изменения в существующей архитектуре приложения.
Проблема и текущая архитектура
Вы используете MERN-стек (MongoDB, Express, React, Node.js) для реализации чат-приложения, где данные чата хранятся в MongoDB. Однако пользователи подгружаются из внешнего API, предоставляющего SQL идентификаторы. При этом ваша система обмена сообщениями ожидает использования ObjectID из MongoDB. Это несоответствие создает затруднения при взаимодействии пользователей в рамках чата.
Решение
Для достижения целостности вашей архитектуры и эффективного взаимодействия между SQL идентификаторами и MongoDB идентификаторами, выполните следующие шаги:
-
Создание таблицы соответствия:
Ваша система должна содержать таблицу или маппинг, связывающий SQL идентификаторы с ObjectID MongoDB. Это можно реализовать двумя способами:-
Подход 1: Хранение соответствия в базе данных
Создайте новую коллекцию в MongoDB, которая будет содержать информацию о соответствиях между SQL идентификаторами и MongoDB ObjectID. Каждый раз, когда вы извлекаете пользователя из внешнего API, проверяйте существование записи. Если запись отсутствует, создайте новую.Пример структуры коллекции:
const userMappingSchema = new mongoose.Schema({ sqlId: { type: Number, unique: true }, mongoId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' } });
-
Подход 2: Кеширование временных данных
Используйте временное хранилище, например Redis, для кэширования соответствий между SQL и MongoDB идентификаторами с определенным временем жизни. Это улучшит производительность при повторных запросах.
-
-
Модификация логики авторизации:
Вы можете изменить то, как вы рулите пользователями в вашей функцииlogin
. Вместо непосредственного использования SQL идентификатора в сообщениях, проведите поиск соответствия и запоминайте MongoDB ObjectID. Пример:const userMapping = await UserMapping.findOne({ sqlId: apiUser.id }); const userIdToUse = userMapping ? userMapping.mongoId : user._id;
-
Обработка отправки и получения сообщений:
В функцияsendMessage
иgetMessages
, измените логику, так чтобы вместо SQL идентификатора принимался MongoDB ObjectID. Убедитесь, что все функции, которые отправляют сообщения и получают их, используют правильные идентификаторы.Пример изменения метода отправки сообщения:
const { id: receiverSqlId } = req.params; const receiverMapping = await UserMapping.findOne({ sqlId: receiverSqlId }); if (!receiverMapping) { return res.status(404).json({ error: "Receiver not found." }); } const receiverId = receiverMapping.mongoId; // используем mongoId для отправки сообщения
-
Оптимизация архитектуры:
Будущий подход к оптимизации необходимо фокусироваться на производительности. Регулярно очищайте записи соответствий для неактивных пользователей, чтобы избежать роста базы данных. Рассмотрите возможность кэширования данных пользователей для уменьшения количества обращений к внешнему API. -
План проверки и отладки:
Необходимо предусмотреть тестирование интеграционных процессов. Используйте юнит-тесты для проверки логики маппинга, а также функциональные тесты для проверки отправки и получения сообщений.
Заключение
Таким образом, подход к созданию маппинга между SQL идентификаторами и ObjectID в MongoDB позволит обеспечить стабильную работу вашего чат-приложения. Внедрение соответствия идентификаторов не только оптимизирует процесс работы с пользователями, но и упростит дальнейшие изменения в architecture. Это позволит вашему MERN приложению не только сохранить существующую функциональность, но и улучшить производительность и масштабируемость в будущем.