From f4795d019e834ed802ee1ffd71857fec205d7d1c Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Wed, 25 Feb 2026 15:30:06 +0500 Subject: [PATCH] upravlenie-service --- package.json | 1 + public/navbar.js | 11 +- public/upravlenie.html | 549 ++++++++++++++++ server.js | 5 + upravlenie-service.js | 1385 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1950 insertions(+), 1 deletion(-) create mode 100644 public/upravlenie.html create mode 100644 upravlenie-service.js diff --git a/package.json b/package.json index 88648e7..e3e8002 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "archiver": "^7.0.1", + "axios": "^1.13.5", "bcryptjs": "~2.4.3", "dotenv": "~16.3.1", "express": "^4.21.2", diff --git a/public/navbar.js b/public/navbar.js index 00c0191..5cd20f2 100644 --- a/public/navbar.js +++ b/public/navbar.js @@ -118,7 +118,16 @@ if (currentUser && currentUser.role === 'admin') { id: "admin-btn" }); } - + // 👇 Кнопка админ-панели ТОЛЬКО для admin 👇 + if (currentUser && currentUser.role === 'admin') { + navButtons.push({ + onclick: "window.location.href = '/upravlenie.html'", + className: "nav-btn profile", + icon: "fas fa-cog", + text: "upravlenie", + id: "admin-btn" + }); + } // Кнопка выхода navButtons.push({ onclick: "logout()", diff --git a/public/upravlenie.html b/public/upravlenie.html new file mode 100644 index 0000000..342b0ff --- /dev/null +++ b/public/upravlenie.html @@ -0,0 +1,549 @@ + + + + + + Управление межсервисным взаимодействием + + + +
+

Управление межсервисным взаимодействием

+ +
+ + + +
+ +
+

Список подключений

+ + + + + + + + + + + + + + + + + + +
IDService IDНазваниеТипЛогинЛокальный пользовательСинхронизацияПоследняя синхр.СтатусДействия
+
+ +
+

Статистика синхронизации

+
Загрузка...
+
+ +
+

Логи синхронизации

+
+
+
+ + + + + + + \ No newline at end of file diff --git a/server.js b/server.js index 535afaf..01995c8 100644 --- a/server.js +++ b/server.js @@ -21,6 +21,8 @@ const userManagementAPI = require('./api-users'); const api2Groups = require('./api2-groups'); // const chatAPI = require('./api-chat'); +// Подключаем API для управления межсервисным взаимодействием +const { setupUpravlenieEndpoints } = require('./upravlenie-service'); // const app = express(); const PORT = process.env.PORT || 3000; @@ -1436,6 +1438,9 @@ async function initializeServer() { // Подключаем API для внешних идентификаторов api2Groups(app, db); console.log('✅ API для внешних идентификаторов подключено'); + // Подключаем API для управления межсервисным взаимодействием + const upravlenieService = setupUpravlenieEndpoints(app, db); + console.log('✅ API для управления межсервисным взаимодействием подключено'); // 6. Помечаем сервер как готовый serverReady = true; diff --git a/upravlenie-service.js b/upravlenie-service.js new file mode 100644 index 0000000..22a0525 --- /dev/null +++ b/upravlenie-service.js @@ -0,0 +1,1385 @@ +// upravlenie-service.js +/** + * Модуль управления подключениями между сервисами + * Позволяет организовать цепочку сервисов (организатор -> исполнители) + * Каждый сервис имеет общий service_id (1-4062) для идентификации в цепочке + */ + +const axios = require('axios'); +const FormData = require('form-data'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// Константы +const SERVICE_ID_RANGE = { min: 1, max: 4062 }; +const SYNC_INTERVAL = 60000; // 1 минута +const MAX_RETRY_COUNT = 3; +const RETRY_DELAY = 5000; // 5 секунд + +// Статусы задач для синхронизации +const TaskStatus = { + PENDING: 'pending', + IN_PROGRESS: 'in_progress', + ASSIGNED: 'assigned', + REWORK: 'rework', + COMPLETED: 'completed', + OVERDUE: 'overdue', + CANCELLED: 'cancelled' +}; + +// Направление синхронизации +const SyncDirection = { + INCOMING: 'incoming', // Получение задач от организатора + OUTGOING: 'outgoing' // Отправка статусов исполнителю +}; + +// Класс для работы с подключениями между сервисами +class UpravlenieService { + constructor(db) { + this.db = db; + this.syncIntervals = new Map(); // Интервалы синхронизации для каждого подключения + this.syncInProgress = new Set(); // Подключения в процессе синхронизации + this.init(); + } + + /** + * Инициализация сервиса + */ + async init() { + console.log('🔧 Инициализация сервиса Upravlenie...'); + await this.createTable(); + await this.startAllSyncJobs(); + console.log('✅ Сервис Upravlenie инициализирован'); + } + + /** + * Создание таблицы upravlenie + */ + async createTable() { + const createTableSQL = ` + CREATE TABLE IF NOT EXISTS upravlenie ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL CHECK(service_id >= 1 AND service_id <= 4062), + service_name VARCHAR(255) NOT NULL, + service_type VARCHAR(50) NOT NULL CHECK(service_type IN ('organizer', 'executor')), + login VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + api_url VARCHAR(500), + local_user_id INTEGER, + local_user_login VARCHAR(255), + sync_direction VARCHAR(50) DEFAULT 'outgoing' CHECK(sync_direction IN ('incoming', 'outgoing', 'both')), + sync_enabled BOOLEAN DEFAULT 1, + sync_interval INTEGER DEFAULT 60, + last_sync_at TIMESTAMP, + last_sync_status VARCHAR(50), + last_sync_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + FOREIGN KEY (local_user_id) REFERENCES users(id) + ) + `; + + return new Promise((resolve, reject) => { + this.db.run(createTableSQL, (err) => { + if (err) { + console.error('❌ Ошибка создания таблицы upravlenie:', err); + reject(err); + } else { + console.log('✅ Таблица upravlenie создана/проверена'); + this.createIndexes().then(resolve).catch(reject); + } + }); + }); + } + + /** + * Создание индексов для таблицы upravlenie + */ + async createIndexes() { + const indexes = [ + 'CREATE INDEX IF NOT EXISTS idx_upravlenie_service_id ON upravlenie(service_id)', + 'CREATE INDEX IF NOT EXISTS idx_upravlenie_service_type ON upravlenie(service_type)', + 'CREATE INDEX IF NOT EXISTS idx_upravlenie_local_user_id ON upravlenie(local_user_id)', + 'CREATE INDEX IF NOT EXISTS idx_upravlenie_sync_enabled ON upravlenie(sync_enabled)', + 'CREATE INDEX IF NOT EXISTS idx_upravlenie_last_sync ON upravlenie(last_sync_at)' + ]; + + return new Promise((resolve, reject) => { + this.db.serialize(() => { + let error = null; + indexes.forEach(indexSQL => { + this.db.run(indexSQL, (err) => { + if (err) error = err; + }); + }); + + if (error) { + console.error('❌ Ошибка создания индексов:', error); + reject(error); + } else { + console.log('✅ Индексы для upravlenie созданы'); + resolve(); + } + }); + }); + } + + /** + * Создание нового подключения + */ + async createConnection(data) { + const { + service_id, service_name, service_type, login, password, + api_url, local_user_id, local_user_login, sync_direction = 'outgoing', + sync_enabled = 1, sync_interval = 60 + } = data; + + // Валидация service_id + if (service_id < SERVICE_ID_RANGE.min || service_id > SERVICE_ID_RANGE.max) { + throw new Error(`service_id должен быть в диапазоне ${SERVICE_ID_RANGE.min}-${SERVICE_ID_RANGE.max}`); + } + + // Для организатора api_url может быть пустым + if (service_type === 'executor' && !api_url) { + throw new Error('Для исполнителя необходимо указать api_url организатора'); + } + + // Проверяем уникальность service_id для активных подключений + const existing = await this.getConnectionByServiceId(service_id); + if (existing && existing.is_active) { + throw new Error(`Подключение с service_id ${service_id} уже существует`); + } + + const sql = ` + INSERT INTO upravlenie ( + service_id, service_name, service_type, login, password, + api_url, local_user_id, local_user_login, sync_direction, + sync_enabled, sync_interval + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + return new Promise((resolve, reject) => { + this.db.run(sql, [ + service_id, service_name, service_type, login, password, + api_url, local_user_id || null, local_user_login || null, + sync_direction, sync_enabled, sync_interval + ], function(err) { + if (err) { + reject(err); + } else { + const connectionId = this.lastID; + console.log(`✅ Создано подключение ${service_name} (ID: ${connectionId}, service_id: ${service_id})`); + + // Запускаем синхронизацию для нового подключения + if (sync_enabled) { + this.startSyncJob(connectionId, sync_interval); + } + + resolve({ id: connectionId, ...data }); + } + }); + }); + } + + /** + * Получение подключения по ID + */ + getConnection(id) { + return new Promise((resolve, reject) => { + this.db.get('SELECT * FROM upravlenie WHERE id = ?', [id], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + } + + /** + * Получение подключения по service_id + */ + getConnectionByServiceId(serviceId) { + return new Promise((resolve, reject) => { + this.db.get( + 'SELECT * FROM upravlenie WHERE service_id = ? AND is_active = 1', + [serviceId], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + } + + /** + * Получение всех подключений + */ + getAllConnections(filters = {}) { + return new Promise((resolve, reject) => { + let sql = 'SELECT * FROM upravlenie WHERE 1=1'; + const params = []; + + if (filters.service_type) { + sql += ' AND service_type = ?'; + params.push(filters.service_type); + } + + if (filters.is_active !== undefined) { + sql += ' AND is_active = ?'; + params.push(filters.is_active ? 1 : 0); + } + + if (filters.sync_enabled !== undefined) { + sql += ' AND sync_enabled = ?'; + params.push(filters.sync_enabled ? 1 : 0); + } + + sql += ' ORDER BY service_name'; + + this.db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } + + /** + * Обновление подключения + */ + async updateConnection(id, data) { + const fields = []; + const values = []; + + const allowedFields = [ + 'service_name', 'service_type', 'login', 'password', + 'api_url', 'local_user_id', 'local_user_login', + 'sync_direction', 'sync_enabled', 'sync_interval', + 'is_active' + ]; + + allowedFields.forEach(field => { + if (data[field] !== undefined) { + fields.push(`${field} = ?`); + values.push(data[field]); + } + }); + + if (fields.length === 0) { + throw new Error('Нет данных для обновления'); + } + + fields.push('updated_at = CURRENT_TIMESTAMP'); + values.push(id); + + const sql = `UPDATE upravlenie SET ${fields.join(', ')} WHERE id = ?`; + + return new Promise((resolve, reject) => { + this.db.run(sql, values, function(err) { + if (err) { + reject(err); + } else { + console.log(`✅ Подключение ${id} обновлено`); + + // Перезапускаем задачу синхронизации если изменились настройки + if (data.sync_enabled !== undefined || data.sync_interval !== undefined) { + this.restartSyncJob(id); + } + + resolve({ id, changes: this.changes }); + } + }); + }); + } + + /** + * Удаление подключения (мягкое удаление) + */ + async deleteConnection(id) { + return this.updateConnection(id, { is_active: 0 }); + } + + /** + * Запуск всех задач синхронизации + */ + async startAllSyncJobs() { + const connections = await this.getAllConnections({ + is_active: true, + sync_enabled: true + }); + + connections.forEach(conn => { + this.startSyncJob(conn.id, conn.sync_interval); + }); + + console.log(`✅ Запущено ${connections.length} задач синхронизации`); + } + + /** + * Запуск задачи синхронизации для конкретного подключения + */ + startSyncJob(connectionId, intervalMinutes) { + this.stopSyncJob(connectionId); + + const intervalMs = intervalMinutes * 60 * 1000; + const interval = setInterval(() => { + this.syncConnection(connectionId); + }, intervalMs); + + this.syncIntervals.set(connectionId, interval); + console.log(`✅ Запущена синхронизация для подключения ${connectionId} (интервал: ${intervalMinutes} мин)`); + + // Выполняем первую синхронизацию сразу + setTimeout(() => { + this.syncConnection(connectionId); + }, 1000); + } + + /** + * Остановка задачи синхронизации + */ + stopSyncJob(connectionId) { + if (this.syncIntervals.has(connectionId)) { + clearInterval(this.syncIntervals.get(connectionId)); + this.syncIntervals.delete(connectionId); + console.log(`✅ Остановлена синхронизация для подключения ${connectionId}`); + } + } + + /** + * Перезапуск задачи синхронизации + */ + async restartSyncJob(connectionId) { + this.stopSyncJob(connectionId); + + const connection = await this.getConnection(connectionId); + if (connection && connection.is_active && connection.sync_enabled) { + this.startSyncJob(connectionId, connection.sync_interval); + } + } + + /** + * Синхронизация с подключенным сервисом + */ + async syncConnection(connectionId) { + // Предотвращаем одновременную синхронизацию одного подключения + if (this.syncInProgress.has(connectionId)) { + console.log(`⚠️ Синхронизация подключения ${connectionId} уже выполняется`); + return; + } + + this.syncInProgress.add(connectionId); + + try { + const connection = await this.getConnection(connectionId); + if (!connection || !connection.is_active || !connection.sync_enabled) { + return; + } + + console.log(`🔄 Синхронизация подключения ${connection.service_name} (${connection.service_id})...`); + + // Обновляем время последней синхронизации + await this.updateSyncStatus(connectionId, 'in_progress'); + + if (connection.service_type === 'organizer') { + // Для организатора: получаем статусы от исполнителей + await this.syncFromExecutors(connection); + } else { + // Для исполнителя: отправляем статусы организатору + await this.syncToOrganizer(connection); + } + + await this.updateSyncStatus(connectionId, 'success'); + console.log(`✅ Синхронизация подключения ${connection.service_name} завершена`); + + } catch (error) { + console.error(`❌ Ошибка синхронизации подключения ${connectionId}:`, error.message); + await this.updateSyncStatus(connectionId, 'error', error.message); + } finally { + this.syncInProgress.delete(connectionId); + } + } + + /** + * Обновление статуса синхронизации + */ + updateSyncStatus(connectionId, status, error = null) { + return new Promise((resolve, reject) => { + this.db.run( + `UPDATE upravlenie + SET last_sync_at = CURRENT_TIMESTAMP, + last_sync_status = ?, + last_sync_error = ? + WHERE id = ?`, + [status, error, connectionId], + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }); + } + + /** + * Синхронизация от исполнителя к организатору + */ + async syncToOrganizer(connection) { + if (!connection.api_url) { + throw new Error('Для исполнителя не указан api_url организатора'); + } + + // Получаем задачи, назначенные локальному пользователю + const localTasks = await this.getTasksForLocalUser(connection.local_user_id); + + // Получаем задачи от организатора + const organizerTasks = await this.fetchTasksFromOrganizer(connection); + + // Обновляем локальные задачи + await this.syncTasksWithOrganizer(connection, organizerTasks, localTasks); + + // Отправляем обновленные статусы организатору + await this.sendTaskStatusesToOrganizer(connection, localTasks); + } + + /** + * Синхронизация от организатора к исполнителям + */ + async syncFromExecutors(connection) { + // Получаем все активные подключения исполнителей с таким же service_id + const executors = await this.getAllConnections({ + service_type: 'executor', + is_active: true, + sync_enabled: true + }); + + for (const executor of executors) { + try { + // Получаем статусы задач от исполнителя + const taskStatuses = await this.fetchTaskStatusesFromExecutor(executor); + + // Обновляем статусы в локальной БД + await this.updateTaskStatusesFromExecutor(executor, taskStatuses); + + } catch (error) { + console.error(`❌ Ошибка получения статусов от исполнителя ${executor.service_name}:`, error.message); + } + } + } + + /** + * Получение задач для локального пользователя + */ + getTasksForLocalUser(userId) { + return new Promise((resolve, reject) => { + if (!userId) { + return resolve([]); + } + + this.db.all(` + SELECT t.*, ta.status as assignment_status, ta.due_date as assignment_due_date + FROM tasks t + JOIN task_assignments ta ON t.id = ta.task_id + WHERE ta.user_id = ? AND t.status = 'active' AND t.closed_at IS NULL + ORDER BY t.created_at DESC + `, [userId], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } + + /** + * Получение задач от организатора через API + */ + async fetchTasksFromOrganizer(connection) { + try { + const response = await axios.get(`${connection.api_url}/api/external/tasks`, { + params: { + service_id: connection.service_id, + user_login: connection.local_user_login + }, + auth: { + username: connection.login, + password: connection.password + }, + timeout: 10000 + }); + + return response.data.tasks || []; + } catch (error) { + console.error(`❌ Ошибка получения задач от организатора:`, error.message); + throw new Error(`Не удалось получить задачи: ${error.message}`); + } + } + + /** + * Получение статусов задач от исполнителя + */ + async fetchTaskStatusesFromExecutor(executor) { + try { + const response = await axios.get(`${executor.api_url}/api/external/task-statuses`, { + params: { + service_id: executor.service_id + }, + auth: { + username: executor.login, + password: executor.password + }, + timeout: 10000 + }); + + return response.data.statuses || []; + } catch (error) { + console.error(`❌ Ошибка получения статусов от исполнителя:`, error.message); + throw error; + } + } + + /** + * Отправка статусов задач организатору + */ + async sendTaskStatusesToOrganizer(connection, localTasks) { + const statuses = localTasks.map(task => ({ + task_id: task.id, + external_task_id: task.external_task_id, + status: task.assignment_status, + completed_at: task.closed_at, + comment: task.rework_comment + })); + + try { + await axios.post(`${connection.api_url}/api/external/task-statuses`, { + service_id: connection.service_id, + statuses: statuses + }, { + auth: { + username: connection.login, + password: connection.password + }, + timeout: 10000 + }); + + console.log(`✅ Отправлено ${statuses.length} статусов организатору`); + } catch (error) { + console.error(`❌ Ошибка отправки статусов организатору:`, error.message); + throw error; + } + } + + /** + * Синхронизация задач с организатором + */ + async syncTasksWithOrganizer(connection, organizerTasks, localTasks) { + for (const organizerTask of organizerTasks) { + const localTask = localTasks.find(t => t.external_task_id === organizerTask.id); + + if (!localTask) { + // Новая задача от организатора - создаем локально + await this.createTaskFromOrganizer(connection, organizerTask); + } else { + // Существующая задача - обновляем если нужно + await this.updateTaskFromOrganizer(connection, organizerTask, localTask); + } + } + } + + /** + * Создание задачи из данных организатора + */ + async createTaskFromOrganizer(connection, taskData) { + return new Promise((resolve, reject) => { + this.db.serialize(() => { + // Создаем задачу + this.db.run( + `INSERT INTO tasks ( + title, description, status, created_by, + external_task_id, external_service_id, start_date, due_date, task_type + ) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, 'external')`, + [ + taskData.title, + taskData.description || '', + connection.local_user_id, + taskData.id, + connection.service_id, + taskData.start_date || new Date().toISOString(), + taskData.due_date || null + ], + function(err) { + if (err) { + reject(err); + return; + } + + const newTaskId = this.lastID; + + // Назначаем задачу локальному пользователю + this.db.run( + `INSERT INTO task_assignments (task_id, user_id, status, start_date, due_date) + VALUES (?, ?, ?, ?, ?)`, + [ + newTaskId, + connection.local_user_id, + taskData.status || 'assigned', + taskData.start_date || new Date().toISOString(), + taskData.due_date || null + ], + (err) => { + if (err) { + reject(err); + return; + } + + console.log(`✅ Создана задача ${newTaskId} от организатора ${connection.service_name}`); + resolve(newTaskId); + } + ); + } + ); + }); + }); + } + + /** + * Обновление задачи из данных организатора + */ + async updateTaskFromOrganizer(connection, organizerTask, localTask) { + return new Promise((resolve, reject) => { + this.db.run( + `UPDATE tasks + SET title = ?, description = ?, due_date = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [organizerTask.title, organizerTask.description || '', organizerTask.due_date || null, localTask.id], + (err) => { + if (err) reject(err); + else { + console.log(`✅ Обновлена задача ${localTask.id} от организатора`); + resolve(); + } + } + ); + }); + } + + /** + * Обновление статусов задач от исполнителя + */ + async updateTaskStatusesFromExecutor(executor, taskStatuses) { + for (const status of taskStatuses) { + await new Promise((resolve, reject) => { + this.db.run( + `UPDATE task_assignments + SET status = ?, updated_at = CURRENT_TIMESTAMP + WHERE task_id = ( + SELECT id FROM tasks + WHERE external_task_id = ? AND external_service_id = ? + ) AND user_id = ?`, + [ + status.status, + status.external_task_id, + executor.service_id, + executor.local_user_id + ], + function(err) { + if (err) reject(err); + else { + if (this.changes > 0) { + console.log(`✅ Обновлен статус задачи ${status.external_task_id}: ${status.status}`); + } + resolve(); + } + } + ); + }); + } + } + + /** + * Загрузка файла в удаленный сервис + */ + async uploadFileToRemote(connection, taskId, fileId) { + return new Promise((resolve, reject) => { + this.db.get( + 'SELECT * FROM task_files WHERE id = ?', + [fileId], + async (err, file) => { + if (err || !file) { + reject(err || new Error('Файл не найден')); + return; + } + + if (!fs.existsSync(file.file_path)) { + reject(new Error('Файл не существует на диске')); + return; + } + + try { + const formData = new FormData(); + formData.append('file', fs.createReadStream(file.file_path), { + filename: file.original_name, + contentType: 'application/octet-stream' + }); + formData.append('task_id', taskId); + formData.append('service_id', connection.service_id); + + const response = await axios.post( + `${connection.api_url}/api/external/upload-file`, + formData, + { + headers: formData.getHeaders(), + auth: { + username: connection.login, + password: connection.password + }, + timeout: 30000, + maxContentLength: Infinity, + maxBodyLength: Infinity + } + ); + + console.log(`✅ Файл ${file.original_name} загружен в удаленный сервис`); + resolve(response.data); + + } catch (error) { + console.error(`❌ Ошибка загрузки файла в удаленный сервис:`, error.message); + reject(error); + } + } + ); + }); + } + + /** + * Получение файла из удаленного сервиса + */ + async downloadFileFromRemote(connection, remoteFileId, localTaskId) { + try { + const response = await axios.get( + `${connection.api_url}/api/external/download-file/${remoteFileId}`, + { + params: { service_id: connection.service_id }, + auth: { + username: connection.login, + password: connection.password + }, + responseType: 'stream', + timeout: 30000 + } + ); + + // Сохраняем файл локально + const { createUserTaskFolder } = require('./database'); + const userFolder = createUserTaskFolder(localTaskId, connection.local_user_login); + + const fileName = response.headers['x-file-name'] || `remote_${Date.now()}.bin`; + const filePath = path.join(userFolder, fileName); + + const writer = fs.createWriteStream(filePath); + response.data.pipe(writer); + + await new Promise((resolve, reject) => { + writer.on('finish', resolve); + writer.on('error', reject); + }); + + // Сохраняем запись в БД + const fileSize = fs.statSync(filePath).size; + + return new Promise((resolve, reject) => { + this.db.run( + `INSERT INTO task_files (task_id, user_id, filename, original_name, file_path, file_size) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + localTaskId, + connection.local_user_id, + fileName, + fileName, + filePath, + fileSize + ], + function(err) { + if (err) reject(err); + else { + console.log(`✅ Файл ${fileName} загружен из удаленного сервиса`); + resolve({ id: this.lastID, filePath, fileName }); + } + } + ); + }); + + } catch (error) { + console.error(`❌ Ошибка загрузки файла из удаленного сервиса:`, error.message); + throw error; + } + } + + /** + * Генерация токена для внешней аутентификации + */ + generateAuthToken(serviceId, login) { + const secret = process.env.EXTERNAL_API_SECRET || 'default_secret_change_me'; + const timestamp = Math.floor(Date.now() / 1000); + const data = `${serviceId}:${login}:${timestamp}`; + const hash = crypto.createHmac('sha256', secret).update(data).digest('hex'); + return `${timestamp}:${hash}`; + } + + /** + * Проверка токена внешней аутентификации + */ + verifyAuthToken(token, serviceId, login) { + try { + const secret = process.env.EXTERNAL_API_SECRET || 'default_secret_change_me'; + const [timestamp, hash] = token.split(':'); + + // Проверяем, что токен не старше 5 минут + const now = Math.floor(Date.now() / 1000); + if (now - parseInt(timestamp) > 300) { + return false; + } + + const data = `${serviceId}:${login}:${timestamp}`; + const expectedHash = crypto.createHmac('sha256', secret).update(data).digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(hash, 'hex'), + Buffer.from(expectedHash, 'hex') + ); + } catch (error) { + return false; + } + } + + /** + * Ручная синхронизация подключения + */ + async manualSync(connectionId) { + console.log(`🔄 Ручная синхронизация подключения ${connectionId}`); + await this.syncConnection(connectionId); + } + + /** + * Получение статистики синхронизации + */ + async getSyncStats() { + const connections = await this.getAllConnections({ is_active: true }); + + const stats = { + total: connections.length, + organizers: 0, + executors: 0, + lastSyncs: [], + errors: [] + }; + + connections.forEach(conn => { + if (conn.service_type === 'organizer') { + stats.organizers++; + } else { + stats.executors++; + } + + if (conn.last_sync_at) { + stats.lastSyncs.push({ + service: conn.service_name, + last_sync: conn.last_sync_at, + status: conn.last_sync_status + }); + } + + if (conn.last_sync_error) { + stats.errors.push({ + service: conn.service_name, + error: conn.last_sync_error, + time: conn.last_sync_at + }); + } + }); + + return stats; + } +} + +// Функция для настройки API endpoints +function setupUpravlenieEndpoints(app, db) { + const upravlenieService = new UpravlenieService(db); + + // Middleware для проверки аутентификации + const requireAuth = (req, res, next) => { + if (!req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + next(); + }; + + // Middleware для проверки прав администратора + const requireAdmin = (req, res, next) => { + if (!req.session.user || req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Требуются права администратора' }); + } + next(); + }; + + // ==================== ВНУТРЕННИЕ API (для администрирования) ==================== + + /** + * GET /api/upravlenie/connections + * Получение всех подключений + */ + app.get('/api/upravlenie/connections', requireAdmin, async (req, res) => { + try { + const filters = { + service_type: req.query.service_type, + is_active: req.query.is_active !== undefined ? req.query.is_active === 'true' : undefined, + sync_enabled: req.query.sync_enabled !== undefined ? req.query.sync_enabled === 'true' : undefined + }; + + const connections = await upravlenieService.getAllConnections(filters); + res.json(connections); + } catch (error) { + console.error('❌ Ошибка получения подключений:', error); + res.status(500).json({ error: error.message }); + } + }); + + /** + * GET /api/upravlenie/connections/:id + * Получение подключения по ID + */ + app.get('/api/upravlenie/connections/:id', requireAdmin, async (req, res) => { + try { + const connection = await upravlenieService.getConnection(req.params.id); + if (!connection) { + return res.status(404).json({ error: 'Подключение не найдено' }); + } + res.json(connection); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + /** + * POST /api/upravlenie/connections + * Создание нового подключения + */ + app.post('/api/upravlenie/connections', requireAdmin, async (req, res) => { + try { + const connection = await upravlenieService.createConnection(req.body); + res.json({ + success: true, + message: 'Подключение создано', + connection + }); + } catch (error) { + console.error('❌ Ошибка создания подключения:', error); + res.status(400).json({ error: error.message }); + } + }); + + /** + * PUT /api/upravlenie/connections/:id + * Обновление подключения + */ + app.put('/api/upravlenie/connections/:id', requireAdmin, async (req, res) => { + try { + const result = await upravlenieService.updateConnection(req.params.id, req.body); + res.json({ + success: true, + message: 'Подключение обновлено', + changes: result.changes + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + + /** + * DELETE /api/upravlenie/connections/:id + * Удаление подключения + */ + app.delete('/api/upravlenie/connections/:id', requireAdmin, async (req, res) => { + try { + await upravlenieService.deleteConnection(req.params.id); + res.json({ success: true, message: 'Подключение удалено' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + /** + * POST /api/upravlenie/connections/:id/sync + * Ручная синхронизация подключения + */ + app.post('/api/upravlenie/connections/:id/sync', requireAdmin, async (req, res) => { + try { + await upravlenieService.manualSync(req.params.id); + res.json({ success: true, message: 'Синхронизация запущена' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + /** + * GET /api/upravlenie/stats + * Статистика синхронизации + */ + app.get('/api/upravlenie/stats', requireAdmin, async (req, res) => { + try { + const stats = await upravlenieService.getSyncStats(); + res.json(stats); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ==================== ВНЕШНИЕ API (для взаимодействия между сервисами) ==================== + + /** + * Middleware для аутентификации внешних запросов + */ + const authenticateExternal = async (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Basic ')) { + return res.status(401).json({ error: 'Требуется базовая аутентификация' }); + } + + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [login, password] = credentials.split(':'); + + try { + // Ищем подключение по логину и паролю + const connection = await new Promise((resolve, reject) => { + db.get( + 'SELECT * FROM upravlenie WHERE login = ? AND password = ? AND is_active = 1', + [login, password], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + + if (!connection) { + return res.status(401).json({ error: 'Неверные учетные данные' }); + } + + req.externalConnection = connection; + next(); + } catch (error) { + res.status(500).json({ error: 'Ошибка аутентификации' }); + } + }; + + /** + * GET /api/external/tasks + * Получение задач для внешнего сервиса + * (используется исполнителем для получения задач от организатора) + */ + app.get('/api/external/tasks', authenticateExternal, async (req, res) => { + try { + const { service_id, user_login } = req.query; + const connection = req.externalConnection; + + // Проверяем соответствие service_id + if (parseInt(service_id) !== connection.service_id) { + return res.status(403).json({ error: 'Неверный service_id' }); + } + + // Если указан user_login, ищем задачи для этого пользователя + let userId = null; + if (user_login) { + const user = await new Promise((resolve) => { + db.get('SELECT id FROM users WHERE login = ?', [user_login], (err, row) => { + resolve(row); + }); + }); + userId = user?.id; + } + + // Получаем задачи + let tasks = []; + if (userId) { + // Задачи для конкретного пользователя + tasks = await new Promise((resolve) => { + db.all(` + SELECT t.*, ta.status as assignment_status + FROM tasks t + JOIN task_assignments ta ON t.id = ta.task_id + WHERE ta.user_id = ? AND t.status = 'active' AND t.closed_at IS NULL + `, [userId], (err, rows) => { + resolve(rows || []); + }); + }); + } else { + // Все активные задачи + tasks = await new Promise((resolve) => { + db.all(` + SELECT t.*, GROUP_CONCAT(ta.user_id) as assigned_users + FROM tasks t + LEFT JOIN task_assignments ta ON t.id = ta.task_id + WHERE t.status = 'active' AND t.closed_at IS NULL + GROUP BY t.id + `, [], (err, rows) => { + resolve(rows || []); + }); + }); + } + + res.json({ + service_id: connection.service_id, + tasks: tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + status: t.assignment_status || t.status, + created_at: t.created_at, + due_date: t.due_date, + assigned_users: t.assigned_users + })) + }); + + } catch (error) { + console.error('❌ Ошибка получения внешних задач:', error); + res.status(500).json({ error: error.message }); + } + }); + + /** + * POST /api/external/task-statuses + * Получение статусов задач от исполнителя + */ + app.post('/api/external/task-statuses', authenticateExternal, async (req, res) => { + try { + const { service_id, statuses } = req.body; + const connection = req.externalConnection; + + if (parseInt(service_id) !== connection.service_id) { + return res.status(403).json({ error: 'Неверный service_id' }); + } + + if (!Array.isArray(statuses)) { + return res.status(400).json({ error: 'statuses должен быть массивом' }); + } + + // Обновляем статусы в локальной БД + for (const status of statuses) { + await new Promise((resolve, reject) => { + db.run( + `UPDATE task_assignments + SET status = ?, updated_at = CURRENT_TIMESTAMP + WHERE task_id = ?`, + [status.status, status.task_id], + function(err) { + if (err) reject(err); + else resolve(); + } + ); + }); + + // Если статус "completed" - закрываем задачу если все исполнители выполнили + if (status.status === 'completed') { + await checkAndCloseTaskIfAllCompleted(status.task_id); + } + } + + res.json({ + success: true, + message: `Получено ${statuses.length} статусов`, + processed: statuses.length + }); + + } catch (error) { + console.error('❌ Ошибка получения статусов от исполнителя:', error); + res.status(500).json({ error: error.message }); + } + }); + + /** + * GET /api/external/task-statuses + * Получение статусов задач для организатора + */ + app.get('/api/external/task-statuses', authenticateExternal, async (req, res) => { + try { + const { service_id } = req.query; + const connection = req.externalConnection; + + if (parseInt(service_id) !== connection.service_id) { + return res.status(403).json({ error: 'Неверный service_id' }); + } + + // Получаем задачи, созданные для этого внешнего сервиса + const statuses = await new Promise((resolve) => { + db.all(` + SELECT + t.id as task_id, + t.external_task_id, + ta.status, + t.closed_at as completed_at, + t.rework_comment as comment + FROM tasks t + JOIN task_assignments ta ON t.id = ta.task_id + WHERE t.external_service_id = ? + `, [service_id], (err, rows) => { + resolve(rows || []); + }); + }); + + res.json({ + service_id: connection.service_id, + statuses: statuses + }); + + } catch (error) { + console.error('❌ Ошибка получения статусов для организатора:', error); + res.status(500).json({ error: error.message }); + } + }); + + /** + * POST /api/external/upload-file + * Загрузка файла от исполнителя + */ + app.post('/api/external/upload-file', authenticateExternal, async (req, res) => { + if (!req.files || !req.files.file) { + return res.status(400).json({ error: 'Файл не загружен' }); + } + + try { + const { task_id, service_id } = req.body; + const file = req.files.file; + const connection = req.externalConnection; + + if (parseInt(service_id) !== connection.service_id) { + return res.status(403).json({ error: 'Неверный service_id' }); + } + + // Создаем папку для задачи + const { createUserTaskFolder } = require('./database'); + const userFolder = createUserTaskFolder(task_id, connection.local_user_login || 'external'); + + // Сохраняем файл + const fileName = `${Date.now()}_${file.name}`; + const filePath = path.join(userFolder, fileName); + + await file.mv(filePath); + + // Сохраняем запись в БД + const result = await new Promise((resolve, reject) => { + db.run( + `INSERT INTO task_files (task_id, user_id, filename, original_name, file_path, file_size) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + task_id, + connection.local_user_id, + fileName, + file.name, + filePath, + file.size + ], + function(err) { + if (err) reject(err); + else resolve({ id: this.lastID }); + } + ); + }); + + res.json({ + success: true, + file_id: result.id, + message: 'Файл успешно загружен' + }); + + } catch (error) { + console.error('❌ Ошибка загрузки внешнего файла:', error); + res.status(500).json({ error: error.message }); + } + }); + + /** + * GET /api/external/download-file/:fileId + * Скачивание файла для исполнителя + */ + app.get('/api/external/download-file/:fileId', authenticateExternal, async (req, res) => { + try { + const { fileId } = req.params; + const { service_id } = req.query; + const connection = req.externalConnection; + + if (parseInt(service_id) !== connection.service_id) { + return res.status(403).json({ error: 'Неверный service_id' }); + } + + // Получаем информацию о файле + const file = await new Promise((resolve, reject) => { + db.get( + 'SELECT * FROM task_files WHERE id = ?', + [fileId], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + + if (!file) { + return res.status(404).json({ error: 'Файл не найден' }); + } + + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: 'Файл не существует на диске' }); + } + + // Отправляем файл + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.original_name)}"`); + res.setHeader('X-File-Name', encodeURIComponent(file.original_name)); + res.setHeader('Content-Type', 'application/octet-stream'); + + fs.createReadStream(file.file_path).pipe(res); + + } catch (error) { + console.error('❌ Ошибка скачивания файла:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Вспомогательная функция для проверки и закрытия задачи + async function checkAndCloseTaskIfAllCompleted(taskId) { + return new Promise((resolve) => { + db.all( + 'SELECT status FROM task_assignments WHERE task_id = ?', + [taskId], + (err, assignments) => { + if (!err && assignments && assignments.length > 0) { + const allCompleted = assignments.every(a => a.status === 'completed'); + + if (allCompleted) { + db.run( + 'UPDATE tasks SET closed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [taskId], + (err) => { + if (!err) { + console.log(`✅ Задача ${taskId} автоматически закрыта`); + } + resolve(); + } + ); + } else { + resolve(); + } + } else { + resolve(); + } + } + ); + }); + } + + return upravlenieService; +} + +module.exports = { + UpravlenieService, + setupUpravlenieEndpoints, + TaskStatus, + SyncDirection +}; \ No newline at end of file