Files
minicrm/upravlenie-service.js
2026-02-25 16:00:39 +05:00

1492 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 организатора');
}
// Проверяем существование локального пользователя если указан
if (local_user_id) {
const userExists = await this.checkLocalUserExists(local_user_id);
if (!userExists) {
throw new Error(`Пользователь с ID ${local_user_id} не существует`);
}
}
// Проверяем уникальность 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})`);
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} обновлено`);
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
});
for (const conn of connections) {
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 организатора');
}
// Проверяем наличие локального пользователя
if (!connection.local_user_id) {
console.warn(`⚠️ Для подключения ${connection.service_name} не указан локальный пользователь. Задачи не будут создаваться.`);
return;
}
// Получаем задачи, назначенные локальному пользователю
const localTasks = await this.getTasksForLocalUser(connection.local_user_id);
// Получаем задачи от организатора
const organizerTasks = await this.fetchTasksFromOrganizer(connection);
if (organizerTasks.length === 0) {
console.log(`📭 Нет новых задач от организатора для ${connection.service_name}`);
return;
}
// Обновляем локальные задачи
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
});
if (executors.length === 0) {
console.log(`📭 Нет активных исполнителей для организатора ${connection.service_name}`);
return;
}
for (const executor of executors) {
try {
// Проверяем наличие локального пользователя у исполнителя
if (!executor.local_user_id) {
console.warn(`⚠️ Для исполнителя ${executor.service_name} не указан локальный пользователь. Пропускаем...`);
continue;
}
// Получаем статусы задач от исполнителя
const taskStatuses = await this.fetchTaskStatusesFromExecutor(executor);
if (taskStatuses.length > 0) {
// Обновляем статусы в локальной БД
await this.updateTaskStatusesFromExecutor(executor, taskStatuses);
console.log(`✅ Получено ${taskStatuses.length} статусов от исполнителя ${executor.service_name}`);
}
} catch (error) {
console.error(`❌ Ошибка получения статусов от исполнителя ${executor.service_name}:`, error.message);
}
}
}
/**
* Проверка существования локального пользователя
*/
async checkLocalUserExists(userId) {
return new Promise((resolve) => {
this.db.get('SELECT id FROM users WHERE id = ?', [userId], (err, row) => {
resolve(!!row);
});
});
}
/**
* Получение задач для локального пользователя
*/
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);
}
}
}
// upravlenie-service.js (исправленная версия метода createTaskFromOrganizer)
/**
* Создание задачи из данных организатора
*/
async createTaskFromOrganizer(connection, taskData) {
return new Promise((resolve, reject) => {
// Проверяем, что local_user_id не NULL
if (!connection.local_user_id) {
reject(new Error('local_user_id не может быть пустым. Укажите локального пользователя для создания задач.'));
return;
}
// Сначала проверяем структуру таблицы tasks
this.db.all("PRAGMA table_info(tasks)", (err, columns) => {
if (err) {
reject(err);
return;
}
const columnNames = columns.map(c => c.name);
// Формируем SQL запрос динамически
let fields = ['title', 'description', 'status', 'created_by', 'start_date', 'due_date', 'task_type'];
let placeholders = ['?', '?', '?', '?', '?', '?', '?'];
let values = [
taskData.title,
taskData.description || '',
'active',
connection.local_user_id, // теперь точно не NULL
taskData.start_date || new Date().toISOString(),
taskData.due_date || null,
'external'
];
// Добавляем external_task_id если колонка существует
if (columnNames.includes('external_task_id')) {
fields.push('external_task_id');
placeholders.push('?');
values.push(taskData.id);
}
// Добавляем external_service_id если колонка существует
if (columnNames.includes('external_service_id')) {
fields.push('external_service_id');
placeholders.push('?');
values.push(connection.service_id);
}
const sql = `INSERT INTO tasks (${fields.join(', ')}) VALUES (${placeholders.join(', ')})`;
console.log(`📝 Создание задачи от организатора:`, {
sql,
values,
local_user_id: connection.local_user_id,
service_id: connection.service_id
});
this.db.run(sql, values, function(err) {
if (err) {
console.error('❌ Ошибка создания задачи:', 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) {
console.error('❌ Ошибка назначения задачи:', 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 = ? AND user_id = ?`,
[
status.status,
status.task_id,
executor.local_user_id
],
function(err) {
if (err) reject(err);
else {
if (this.changes > 0) {
console.log(`✅ Обновлен статус задачи ${status.task_id}: ${status.status}`);
}
resolve();
}
}
);
});
// Если статус "completed" - проверяем и закрываем задачу если все выполнили
if (status.status === 'completed') {
await this.checkAndCloseTaskIfAllCompleted(status.task_id);
}
}
}
/**
* Проверка и закрытие задачи если все исполнители выполнили
*/
async checkAndCloseTaskIfAllCompleted(taskId) {
return new Promise((resolve) => {
this.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) {
this.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();
}
}
);
});
}
/**
* Загрузка файла в удаленный сервис
*/
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 || 'external');
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 || !req.session.user) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
next();
};
// Middleware для проверки прав администратора
const requireAdmin = (req, res, next) => {
if (!req.session || !req.session.user) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
if (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: 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) {
console.error('❌ Ошибка аутентификации:', 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(db, 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(db, 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
};