Как интегрировать числовые идентификаторы SQL из внешнего API с чатом на базе MongoDB в стекe MERN?

Вопрос или проблема

Я работаю над приложением для чата, основанным на стеке MERN, где данные чата хранятся в MongoDB. Однако пользователи получаются из внешнего API, который предоставляет числовые идентификаторы SQL. Система входа работает нормально через перенаправление и полезную нагрузку, но у нас возникают проблемы с отправкой и получением сообщений, так как наша система обмена сообщениями ожидает идентификаторы объекта MongoDB, в то время как пользователи имеют только числовые идентификаторы SQL из API.

**Текущая настройка:
**1. Фронтенд: React (вход через перенаправление, получение полезной нагрузки с данными пользователей).

  1. Бэкэнд: Node.js (Express) с MongoDB для хранения данных чата.
  2. Пользователи: Получены из внешнего API в виде JSON-ответа (с числовыми идентификаторами SQL).
  3. Чат: Сообщения хранятся в 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 идентификаторами, выполните следующие шаги:

  1. Создание таблицы соответствия:
    Ваша система должна содержать таблицу или маппинг, связывающий 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 идентификаторами с определенным временем жизни. Это улучшит производительность при повторных запросах.

  2. Модификация логики авторизации:
    Вы можете изменить то, как вы рулите пользователями в вашей функции login. Вместо непосредственного использования SQL идентификатора в сообщениях, проведите поиск соответствия и запоминайте MongoDB ObjectID. Пример:

    const userMapping = await UserMapping.findOne({ sqlId: apiUser.id });
    const userIdToUse = userMapping ? userMapping.mongoId : user._id;
  3. Обработка отправки и получения сообщений:
    В функция 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 для отправки сообщения
  4. Оптимизация архитектуры:
    Будущий подход к оптимизации необходимо фокусироваться на производительности. Регулярно очищайте записи соответствий для неактивных пользователей, чтобы избежать роста базы данных. Рассмотрите возможность кэширования данных пользователей для уменьшения количества обращений к внешнему API.

  5. План проверки и отладки:
    Необходимо предусмотреть тестирование интеграционных процессов. Используйте юнит-тесты для проверки логики маппинга, а также функциональные тесты для проверки отправки и получения сообщений.

Заключение

Таким образом, подход к созданию маппинга между SQL идентификаторами и ObjectID в MongoDB позволит обеспечить стабильную работу вашего чат-приложения. Внедрение соответствия идентификаторов не только оптимизирует процесс работы с пользователями, но и упростит дальнейшие изменения в architecture. Это позволит вашему MERN приложению не только сохранить существующую функциональность, но и улучшить производительность и масштабируемость в будущем.

Оцените материал
Добавить комментарий

Капча загружается...