feat: новая функция
This commit is contained in:
@@ -108,6 +108,7 @@ const dleAnalyticsRoutes = require('./routes/dleAnalytics'); // Аналитик
|
|||||||
const compileRoutes = require('./routes/compile'); // Компиляция контрактов
|
const compileRoutes = require('./routes/compile'); // Компиляция контрактов
|
||||||
const { router: dleHistoryRoutes } = require('./routes/dleHistory'); // Расширенная история
|
const { router: dleHistoryRoutes } = require('./routes/dleHistory'); // Расширенная история
|
||||||
const systemRoutes = require('./routes/system'); // Добавляем импорт маршрутов системного мониторинга
|
const systemRoutes = require('./routes/system'); // Добавляем импорт маршрутов системного мониторинга
|
||||||
|
const consentRoutes = require('./routes/consent'); // Добавляем импорт маршрутов согласий
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -283,6 +284,7 @@ app.use('/api/identities', identitiesRoutes);
|
|||||||
app.use('/api/rag', ragRoutes); // Подключаем роут
|
app.use('/api/rag', ragRoutes); // Подключаем роут
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
app.use('/api/monitoring', monitoringRoutes);
|
||||||
app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц
|
app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц
|
||||||
|
app.use('/api/consent', consentRoutes); // Добавляем маршрут согласий
|
||||||
app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга
|
app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга
|
||||||
app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы)
|
app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы)
|
||||||
app.use('/api/ens', ensRoutes); // ENS utilities
|
app.use('/api/ens', ensRoutes); // ENS utilities
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const emailAuth = require('../services/emailAuth');
|
|||||||
const verificationService = require('../services/verification-service');
|
const verificationService = require('../services/verification-service');
|
||||||
const identityService = require('../services/identity-service');
|
const identityService = require('../services/identity-service');
|
||||||
const sessionService = require('../services/session-service');
|
const sessionService = require('../services/session-service');
|
||||||
|
// Используем централизованный сервис для работы с согласиями
|
||||||
|
const consentService = require('../services/consentService');
|
||||||
|
const { DOCUMENT_CONSENT_MAP } = consentService;
|
||||||
|
|
||||||
// Создаем лимитер для попыток аутентификации
|
// Создаем лимитер для попыток аутентификации
|
||||||
const authLimiter = rateLimit({
|
const authLimiter = rateLimit({
|
||||||
@@ -173,30 +176,60 @@ router.post('/verify', async (req, res) => {
|
|||||||
const origin = req.get('origin') || 'http://localhost:5173';
|
const origin = req.get('origin') || 'http://localhost:5173';
|
||||||
const domain = new URL(origin).host; // Извлекаем домен из origin
|
const domain = new URL(origin).host; // Извлекаем домен из origin
|
||||||
|
|
||||||
|
// Получаем список документов для подписания и добавляем их в resources
|
||||||
|
const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP);
|
||||||
|
const tableName = 'admin_pages_simple';
|
||||||
|
const tableExistsRes = await db.getQuery()(
|
||||||
|
`SELECT to_regclass($1) as exists`, [tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
let resources = [`${origin}/api/auth/verify`];
|
||||||
|
if (tableExistsRes.rows[0].exists) {
|
||||||
|
const { rows: documents } = await db.getQuery()(`
|
||||||
|
SELECT id FROM ${tableName}
|
||||||
|
WHERE status = 'published'
|
||||||
|
AND visibility = 'public'
|
||||||
|
AND title = ANY($1)
|
||||||
|
`, [documentTitles]);
|
||||||
|
|
||||||
|
// Добавляем ссылки на документы в resources
|
||||||
|
documents.forEach(doc => {
|
||||||
|
resources.push(`${origin}/content/published/${doc.id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем resources для консистентности (должно совпадать с фронтендом)
|
||||||
|
resources = resources.sort();
|
||||||
|
|
||||||
|
// Используем issuedAt из запроса, если он есть, иначе создаем новый
|
||||||
|
const messageIssuedAt = issuedAt || new Date().toISOString();
|
||||||
|
|
||||||
const { SiweMessage } = require('siwe');
|
const { SiweMessage } = require('siwe');
|
||||||
const message = new SiweMessage({
|
const message = new SiweMessage({
|
||||||
domain,
|
domain,
|
||||||
address: normalizedAddress,
|
address: normalizedAddress,
|
||||||
statement: 'Sign in with Ethereum to the app.',
|
statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.',
|
||||||
uri: origin,
|
uri: origin,
|
||||||
version: '1',
|
version: '1',
|
||||||
chainId: 1,
|
chainId: 1,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
issuedAt: issuedAt || new Date().toISOString(),
|
issuedAt: messageIssuedAt,
|
||||||
resources: [`${origin}/api/auth/verify`],
|
resources: resources,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageToSign = message.prepareMessage();
|
const messageToSign = message.prepareMessage();
|
||||||
|
|
||||||
logger.info(`[verify] SIWE message for verification: ${messageToSign}`);
|
logger.info(`[verify] SIWE message for verification: ${messageToSign}`);
|
||||||
|
logger.info(`[verify] Resources: ${JSON.stringify(resources)}`);
|
||||||
|
logger.info(`[verify] IssuedAt: ${messageIssuedAt}`);
|
||||||
logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`);
|
logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`);
|
||||||
logger.info(`[verify] Normalized address: ${normalizedAddress}`);
|
logger.info(`[verify] Normalized address: ${normalizedAddress}`);
|
||||||
logger.info(`[verify] Request headers origin: ${req.get('origin')}`);
|
logger.info(`[verify] Request headers origin: ${req.get('origin')}`);
|
||||||
logger.info(`[verify] Request headers host: ${req.get('host')}`);
|
logger.info(`[verify] Request headers host: ${req.get('host')}`);
|
||||||
logger.info(`[verify] Request headers referer: ${req.get('referer')}`);
|
logger.info(`[verify] Request headers referer: ${req.get('referer')}`);
|
||||||
|
|
||||||
// Проверяем подпись
|
// Проверяем подпись через SiweMessage.verify() (передаем объект сообщения, а не строку)
|
||||||
const isValid = await authService.verifySignature(messageToSign, signature, normalizedAddress);
|
const isValid = await authService.verifySignature(message, signature, normalizedAddress);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
logger.error(`[verify] Invalid signature for address: ${normalizedAddress}`);
|
logger.error(`[verify] Invalid signature for address: ${normalizedAddress}`);
|
||||||
return res.status(401).json({ success: false, error: 'Invalid signature' });
|
return res.status(401).json({ success: false, error: 'Invalid signature' });
|
||||||
@@ -257,6 +290,15 @@ router.post('/verify', async (req, res) => {
|
|||||||
// Связываем гостевые сообщения с пользователем
|
// Связываем гостевые сообщения с пользователем
|
||||||
await sessionService.linkGuestMessages(req.session, userId);
|
await sessionService.linkGuestMessages(req.session, userId);
|
||||||
|
|
||||||
|
// Проверяем согласия пользователя через централизованный сервис
|
||||||
|
const consentCheck = await consentService.checkConsents({
|
||||||
|
userId,
|
||||||
|
walletAddress: normalizedAddress
|
||||||
|
});
|
||||||
|
|
||||||
|
const needsConsent = consentCheck.needsConsent;
|
||||||
|
const missingConsents = consentCheck.missingConsents;
|
||||||
|
|
||||||
// Возвращаем успешный ответ
|
// Возвращаем успешный ответ
|
||||||
userAccessLevel = await authService.getUserAccessLevel(normalizedAddress);
|
userAccessLevel = await authService.getUserAccessLevel(normalizedAddress);
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -265,6 +307,8 @@ router.post('/verify', async (req, res) => {
|
|||||||
address: normalizedAddress, // Возвращаем нормализованный адрес
|
address: normalizedAddress, // Возвращаем нормализованный адрес
|
||||||
userAccessLevel: userAccessLevel,
|
userAccessLevel: userAccessLevel,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
needsConsent: needsConsent,
|
||||||
|
missingConsents: missingConsents,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[verify] Error:', error);
|
logger.error('[verify] Error:', error);
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const aiAssistantRulesService = require('../services/aiAssistantRulesService');
|
|||||||
const botManager = require('../services/botManager');
|
const botManager = require('../services/botManager');
|
||||||
const universalMediaProcessor = require('../services/UniversalMediaProcessor');
|
const universalMediaProcessor = require('../services/UniversalMediaProcessor');
|
||||||
|
|
||||||
|
// Маппинг названий документов на типы согласий
|
||||||
|
const DOCUMENT_CONSENT_MAP = {
|
||||||
|
'Политика конфиденциальности': 'privacy_policy',
|
||||||
|
'Права субъектов персональных данных и отзыв согласия': 'personal_data',
|
||||||
|
'Согласие на использование файлов cookie': 'cookies',
|
||||||
|
'Согласие на обработку персональных данных': 'personal_data_processing',
|
||||||
|
};
|
||||||
|
|
||||||
// Настройка multer для обработки файлов в памяти
|
// Настройка multer для обработки файлов в памяти
|
||||||
const storage = multer.memoryStorage();
|
const storage = multer.memoryStorage();
|
||||||
const upload = multer({ storage: storage });
|
const upload = multer({ storage: storage });
|
||||||
@@ -143,6 +151,7 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Обработка через unified processor
|
// Обработка через unified processor
|
||||||
|
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
|
||||||
const result = await unifiedMessageProcessor.processMessage(messageData);
|
const result = await unifiedMessageProcessor.processMessage(messageData);
|
||||||
|
|
||||||
logger.info('[Chat] Результат обработки:', {
|
logger.info('[Chat] Результат обработки:', {
|
||||||
@@ -151,13 +160,25 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
|
|||||||
aiResponseType: typeof result.aiResponse?.response
|
aiResponseType: typeof result.aiResponse?.response
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
// Формируем ответ
|
||||||
|
// Системное сообщение уже включено в ответ ИИ (если нужно)
|
||||||
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
guestId: webGuestId,
|
guestId: webGuestId,
|
||||||
aiResponse: result.aiResponse ? {
|
aiResponse: result.aiResponse ? {
|
||||||
response: result.aiResponse.response
|
response: result.aiResponse.response
|
||||||
} : null
|
} : null
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Добавляем информацию о согласиях из результата (если есть)
|
||||||
|
if (result.consentRequired) {
|
||||||
|
response.consentRequired = result.consentRequired;
|
||||||
|
response.missingConsents = result.missingConsents;
|
||||||
|
response.consentDocuments = result.consentDocuments;
|
||||||
|
response.autoConsentOnReply = result.autoConsentOnReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Chat] Ошибка обработки гостевого сообщения:', error);
|
logger.error('[Chat] Ошибка обработки гостевого сообщения:', error);
|
||||||
@@ -268,9 +289,11 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Обработка через unified processor
|
// Обработка через unified processor
|
||||||
|
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
|
||||||
const result = await unifiedMessageProcessor.processMessage(messageData);
|
const result = await unifiedMessageProcessor.processMessage(messageData);
|
||||||
|
|
||||||
res.json({
|
// Формируем ответ с информацией о согласиях
|
||||||
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
userMessageId: result.userMessageId,
|
userMessageId: result.userMessageId,
|
||||||
conversationId: result.conversationId,
|
conversationId: result.conversationId,
|
||||||
@@ -278,7 +301,17 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
|
|||||||
response: result.aiResponse.response
|
response: result.aiResponse.response
|
||||||
} : null,
|
} : null,
|
||||||
noAiResponse: result.noAiResponse
|
noAiResponse: result.noAiResponse
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Добавляем информацию о согласиях из результата (если есть)
|
||||||
|
if (result.consentRequired) {
|
||||||
|
response.consentRequired = result.consentRequired;
|
||||||
|
response.missingConsents = result.missingConsents;
|
||||||
|
response.consentDocuments = result.consentDocuments;
|
||||||
|
response.autoConsentOnReply = result.autoConsentOnReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Chat] Ошибка обработки сообщения:', error);
|
logger.error('[Chat] Ошибка обработки сообщения:', error);
|
||||||
|
|||||||
289
backend/routes/consent.js
Normal file
289
backend/routes/consent.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This software is proprietary and confidential.
|
||||||
|
* Unauthorized copying, modification, or distribution is prohibited.
|
||||||
|
*
|
||||||
|
* For licensing inquiries: info@hb3-accelerator.com
|
||||||
|
* Website: https://hb3-accelerator.com
|
||||||
|
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const consentService = require('../services/consentService');
|
||||||
|
const { DOCUMENT_CONSENT_MAP } = consentService;
|
||||||
|
|
||||||
|
// Получить список документов для подписания
|
||||||
|
router.get('/documents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tableName = 'admin_pages_simple';
|
||||||
|
|
||||||
|
// Проверяем, есть ли таблица
|
||||||
|
const existsRes = await db.getQuery()(
|
||||||
|
`SELECT to_regclass($1) as exists`, [tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsRes.rows[0].exists) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем документы для подписания по названиям
|
||||||
|
const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP);
|
||||||
|
const { rows } = await db.getQuery()(`
|
||||||
|
SELECT id, title, summary, content, created_at, updated_at
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE status = 'published'
|
||||||
|
AND visibility = 'public'
|
||||||
|
AND title = ANY($1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [documentTitles]);
|
||||||
|
|
||||||
|
// Добавляем тип согласия к каждому документу
|
||||||
|
const documents = rows.map(doc => ({
|
||||||
|
...doc,
|
||||||
|
consentType: DOCUMENT_CONSENT_MAP[doc.title] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(documents);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching consent documents:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверить, подписал ли пользователь необходимые документы
|
||||||
|
router.get('/status', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const walletAddress = req.session.address;
|
||||||
|
|
||||||
|
if (!userId && !walletAddress) {
|
||||||
|
return res.status(400).json({ error: 'Требуется авторизация' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все необходимые типы согласий
|
||||||
|
const requiredConsentTypes = Object.values(DOCUMENT_CONSENT_MAP);
|
||||||
|
|
||||||
|
// Получаем активные согласия пользователя
|
||||||
|
let query = `
|
||||||
|
SELECT consent_type, status, signed_at, document_id, document_title
|
||||||
|
FROM consent_logs
|
||||||
|
WHERE status = 'granted'
|
||||||
|
AND (
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
if (userId) {
|
||||||
|
query += `user_id = $${params.length + 1}`;
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
if (walletAddress) {
|
||||||
|
if (params.length > 0) query += ' OR ';
|
||||||
|
query += `wallet_address = $${params.length + 1}`;
|
||||||
|
params.push(walletAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `)
|
||||||
|
AND consent_type = ANY($${params.length + 1})
|
||||||
|
ORDER BY signed_at DESC
|
||||||
|
`;
|
||||||
|
params.push(requiredConsentTypes);
|
||||||
|
|
||||||
|
const { rows } = await db.getQuery()(query, params);
|
||||||
|
|
||||||
|
// Формируем статус для каждого типа согласия
|
||||||
|
const status = {};
|
||||||
|
requiredConsentTypes.forEach(type => {
|
||||||
|
const consent = rows.find(r => r.consent_type === type);
|
||||||
|
status[type] = consent ? {
|
||||||
|
granted: true,
|
||||||
|
signedAt: consent.signed_at,
|
||||||
|
documentId: consent.document_id,
|
||||||
|
documentTitle: consent.document_title,
|
||||||
|
} : {
|
||||||
|
granted: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем, все ли согласия предоставлены
|
||||||
|
const allGranted = requiredConsentTypes.every(type => status[type].granted);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
allGranted,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking consent status:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сохранить согласие пользователя
|
||||||
|
router.post('/grant', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Разрешаем подпись как для авторизованных, так и для гостей
|
||||||
|
const userId = req.session?.userId || null;
|
||||||
|
const walletAddress = req.session?.address || null;
|
||||||
|
const guestId = req.session?.guestId || null;
|
||||||
|
const { documentIds, consentTypes } = req.body; // Массивы ID документов и типов согласий
|
||||||
|
|
||||||
|
// Если нет ни userId, ни walletAddress, используем guestId для идентификации
|
||||||
|
if (!userId && !walletAddress && !guestId) {
|
||||||
|
return res.status(400).json({ error: 'Требуется идентификация (авторизация или гостевая сессия)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!documentIds || !Array.isArray(documentIds) || documentIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Требуется указать документы для подписания' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о документах
|
||||||
|
const { rows: documents } = await db.getQuery()(`
|
||||||
|
SELECT id, title
|
||||||
|
FROM admin_pages_simple
|
||||||
|
WHERE id = ANY($1) AND status = 'published'
|
||||||
|
`, [documentIds]);
|
||||||
|
|
||||||
|
if (documents.length !== documentIds.length) {
|
||||||
|
return res.status(400).json({ error: 'Некоторые документы не найдены' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.get('user-agent');
|
||||||
|
|
||||||
|
// Сохраняем согласия для каждого документа
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < documents.length; i++) {
|
||||||
|
const doc = documents[i];
|
||||||
|
const consentType = consentTypes && consentTypes[i]
|
||||||
|
? consentTypes[i]
|
||||||
|
: DOCUMENT_CONSENT_MAP[doc.title];
|
||||||
|
|
||||||
|
if (!consentType) {
|
||||||
|
logger.warn(`Unknown consent type for document: ${doc.title}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, есть ли уже активное согласие
|
||||||
|
let checkQuery = `
|
||||||
|
SELECT id FROM consent_logs
|
||||||
|
WHERE status = 'granted' AND consent_type = $1 AND (
|
||||||
|
`;
|
||||||
|
const checkParams = [consentType];
|
||||||
|
if (userId) {
|
||||||
|
checkQuery += `user_id = $${checkParams.length + 1}`;
|
||||||
|
checkParams.push(userId);
|
||||||
|
}
|
||||||
|
if (walletAddress) {
|
||||||
|
if (checkParams.length > 1) checkQuery += ' OR ';
|
||||||
|
checkQuery += `wallet_address = $${checkParams.length + 1}`;
|
||||||
|
checkParams.push(walletAddress);
|
||||||
|
}
|
||||||
|
// Для гостей проверяем по формату guest_${guestId}
|
||||||
|
if (guestId && !walletAddress) {
|
||||||
|
if (checkParams.length > 1) checkQuery += ' OR ';
|
||||||
|
checkQuery += `wallet_address = $${checkParams.length + 1}`;
|
||||||
|
checkParams.push(`guest_${guestId}`);
|
||||||
|
}
|
||||||
|
checkQuery += ')';
|
||||||
|
|
||||||
|
const existing = await db.getQuery()(checkQuery, checkParams);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Обновляем существующее согласие
|
||||||
|
await db.getQuery()(`
|
||||||
|
UPDATE consent_logs
|
||||||
|
SET document_id = $1,
|
||||||
|
document_title = $2,
|
||||||
|
signed_at = NOW(),
|
||||||
|
revoked_at = NULL,
|
||||||
|
ip_address = $3,
|
||||||
|
user_agent = $4,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $5
|
||||||
|
`, [doc.id, doc.title, ipAddress, userAgent, existing.rows[0].id]);
|
||||||
|
|
||||||
|
results.push({ documentId: doc.id, consentType, action: 'updated' });
|
||||||
|
} else {
|
||||||
|
// Для гостей используем guestId как wallet_address для последующей миграции
|
||||||
|
const consentWalletAddress = walletAddress || (guestId ? `guest_${guestId}` : null);
|
||||||
|
|
||||||
|
// Создаем новое согласие
|
||||||
|
await db.getQuery()(`
|
||||||
|
INSERT INTO consent_logs (
|
||||||
|
user_id, wallet_address, document_id, document_title,
|
||||||
|
consent_type, status, ip_address, user_agent, channel
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, 'granted', $6, $7, 'web')
|
||||||
|
`, [userId, consentWalletAddress, doc.id, doc.title, consentType, ipAddress, userAgent]);
|
||||||
|
|
||||||
|
results.push({ documentId: doc.id, consentType, action: 'created' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Consent granted: userId=${userId}, walletAddress=${walletAddress}, documents=${documentIds.join(',')}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error granting consent:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отозвать согласие
|
||||||
|
router.post('/revoke', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const walletAddress = req.session.address;
|
||||||
|
const { consentTypes } = req.body; // Массив типов согласий для отзыва
|
||||||
|
|
||||||
|
if (!userId && !walletAddress) {
|
||||||
|
return res.status(400).json({ error: 'Требуется авторизация' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!consentTypes || !Array.isArray(consentTypes) || consentTypes.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Требуется указать типы согласий для отзыва' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
UPDATE consent_logs
|
||||||
|
SET status = 'revoked',
|
||||||
|
revoked_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE consent_type = ANY($1) AND status = 'granted' AND (
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [consentTypes];
|
||||||
|
if (userId) {
|
||||||
|
query += `user_id = $${params.length + 1}`;
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
if (walletAddress) {
|
||||||
|
if (params.length > 1) query += ' OR ';
|
||||||
|
query += `wallet_address = $${params.length + 1}`;
|
||||||
|
params.push(walletAddress);
|
||||||
|
}
|
||||||
|
query += ')';
|
||||||
|
|
||||||
|
const { rowCount } = await db.getQuery()(query, params);
|
||||||
|
|
||||||
|
logger.info(`Consent revoked: userId=${userId}, walletAddress=${walletAddress}, types=${consentTypes.join(',')}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
revokedCount: rowCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error revoking consent:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
@@ -330,11 +330,75 @@ class UniversalGuestService {
|
|||||||
|
|
||||||
logger.info(`[UniversalGuestService] Обработка сообщения гостя: ${identifier}`);
|
logger.info(`[UniversalGuestService] Обработка сообщения гостя: ${identifier}`);
|
||||||
|
|
||||||
|
// 0.5. Проверяем, нужно ли автоматически подписать согласие при ответе
|
||||||
|
// Загружаем историю для проверки последнего сообщения
|
||||||
|
const previousHistory = await this.getHistory(identifier);
|
||||||
|
|
||||||
|
// Если в истории есть системное сообщение о согласиях, автоматически подписываем при ответе
|
||||||
|
if (previousHistory.length > 0) {
|
||||||
|
const consentService = require('./consentService');
|
||||||
|
const [provider, providerId] = identifier?.split(':') || [];
|
||||||
|
let walletAddress = null;
|
||||||
|
|
||||||
|
if (provider === 'web' && providerId?.startsWith('guest_')) {
|
||||||
|
walletAddress = providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, было ли последнее сообщение системным с согласием
|
||||||
|
const lastMessage = previousHistory[previousHistory.length - 1];
|
||||||
|
const hasConsentSystemMessage = lastMessage &&
|
||||||
|
(lastMessage.role === 'system' || lastMessage.consentRequired);
|
||||||
|
|
||||||
|
if (hasConsentSystemMessage) {
|
||||||
|
// Проверяем текущие согласия
|
||||||
|
const consentCheck = await consentService.checkConsents({
|
||||||
|
userId: null,
|
||||||
|
walletAddress
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если согласия нужны, автоматически подписываем
|
||||||
|
if (consentCheck.needsConsent) {
|
||||||
|
logger.info(`[UniversalGuestService] Автоматическое подписание согласий при ответе гостя ${identifier}`);
|
||||||
|
|
||||||
|
const consentDocuments = await consentService.getConsentDocuments(consentCheck.missingConsents);
|
||||||
|
const documentIds = consentDocuments.map(doc => doc.id);
|
||||||
|
const consentTypes = consentDocuments.map(doc => doc.consentType).filter(type => type);
|
||||||
|
|
||||||
|
if (documentIds.length > 0 && consentTypes.length > 0) {
|
||||||
|
const db = require('../db');
|
||||||
|
try {
|
||||||
|
// Для гостей используем wallet_address в формате guest_ID
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO consent_logs (wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at)
|
||||||
|
SELECT $1, unnest($2::int[]), unnest($3::text[]), unnest($4::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW()
|
||||||
|
ON CONFLICT (wallet_address, consent_type, document_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
status = 'granted',
|
||||||
|
signed_at = NOW(),
|
||||||
|
revoked_at = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE consent_logs.wallet_address = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
|
||||||
|
[
|
||||||
|
walletAddress,
|
||||||
|
documentIds,
|
||||||
|
consentDocuments.map(doc => doc.title),
|
||||||
|
consentTypes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
logger.info(`[UniversalGuestService] Согласия автоматически подписаны для гостя ${identifier}`);
|
||||||
|
} catch (consentError) {
|
||||||
|
logger.error(`[UniversalGuestService] Ошибка автоматического подписания согласий:`, consentError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Сохраняем сообщение гостя
|
// 1. Сохраняем сообщение гостя
|
||||||
const saveResult = await this.saveMessage(messageData);
|
const saveResult = await this.saveMessage(messageData);
|
||||||
const processedContent = saveResult.processedContent;
|
const processedContent = saveResult.processedContent;
|
||||||
|
|
||||||
// 2. Загружаем историю для контекста
|
// 2. Загружаем историю для контекста (заново, так как могли добавиться сообщения)
|
||||||
const conversationHistory = await this.getHistory(identifier);
|
const conversationHistory = await this.getHistory(identifier);
|
||||||
|
|
||||||
// 3. Генерируем AI ответ
|
// 3. Генерируем AI ответ
|
||||||
@@ -368,25 +432,74 @@ class UniversalGuestService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Сохраняем AI ответ
|
// Проверяем согласия для добавления системного сообщения к ответу ИИ
|
||||||
|
const consentService = require('./consentService');
|
||||||
|
const [provider, providerId] = identifier?.split(':') || [];
|
||||||
|
let walletAddress = null;
|
||||||
|
|
||||||
|
if (provider === 'web' && providerId?.startsWith('guest_')) {
|
||||||
|
walletAddress = providerId; // Для веб-гостей используем guest_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
const consentCheck = await consentService.checkConsents({
|
||||||
|
userId: null,
|
||||||
|
walletAddress
|
||||||
|
});
|
||||||
|
|
||||||
|
// Формируем финальный ответ ИИ с системным сообщением, если нужно
|
||||||
|
let finalAiResponse = aiResponse.response;
|
||||||
|
let consentInfo = null;
|
||||||
|
|
||||||
|
if (consentCheck.needsConsent) {
|
||||||
|
const consentSystemMessage = await consentService.getConsentSystemMessage({
|
||||||
|
userId: null,
|
||||||
|
walletAddress,
|
||||||
|
channel: channel === 'web' ? 'web' : channel,
|
||||||
|
baseUrl: process.env.BASE_URL || 'http://localhost:9000'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (consentSystemMessage && consentSystemMessage.consentRequired) {
|
||||||
|
// Добавляем системное сообщение к ответу ИИ
|
||||||
|
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
|
||||||
|
|
||||||
|
consentInfo = {
|
||||||
|
consentRequired: true,
|
||||||
|
missingConsents: consentSystemMessage.missingConsents,
|
||||||
|
consentDocuments: consentSystemMessage.consentDocuments,
|
||||||
|
autoConsentOnReply: consentSystemMessage.autoConsentOnReply
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Сохраняем AI ответ с добавленным системным сообщением
|
||||||
await this.saveAiResponse({
|
await this.saveAiResponse({
|
||||||
identifier,
|
identifier,
|
||||||
content: aiResponse.response,
|
content: finalAiResponse,
|
||||||
channel,
|
channel,
|
||||||
metadata: messageData.metadata || {}
|
metadata: messageData.metadata || {}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[UniversalGuestService] Сообщение гостя ${identifier} обработано успешно`);
|
logger.info(`[UniversalGuestService] Сообщение гостя ${identifier} обработано успешно`);
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
identifier,
|
identifier,
|
||||||
aiResponse: {
|
aiResponse: {
|
||||||
response: aiResponse.response,
|
response: finalAiResponse,
|
||||||
ragData: aiResponse.ragData
|
ragData: aiResponse.ragData
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Добавляем информацию о согласиях, если они нужны
|
||||||
|
if (consentInfo) {
|
||||||
|
result.consentRequired = consentInfo.consentRequired;
|
||||||
|
result.missingConsents = consentInfo.missingConsents;
|
||||||
|
result.consentDocuments = consentInfo.consentDocuments;
|
||||||
|
result.autoConsentOnReply = consentInfo.autoConsentOnReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[UniversalGuestService] Ошибка обработки сообщения гостя:', error);
|
logger.error('[UniversalGuestService] Ошибка обработки сообщения гостя:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -534,6 +647,48 @@ class UniversalGuestService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Переносим согласия гостя на пользователя, если они есть
|
||||||
|
// Согласия могут быть связаны с гостевой сессией через wallet_address = "guest_${guestId}"
|
||||||
|
try {
|
||||||
|
const [channel, guestId] = identifier.split(':');
|
||||||
|
|
||||||
|
// Ищем согласия по гостевому идентификатору в формате "guest_${guestId}"
|
||||||
|
const guestWalletAddress = `guest_${guestId}`;
|
||||||
|
|
||||||
|
const { rows: guestConsents } = await db.getQuery()(`
|
||||||
|
SELECT id, consent_type, document_id, document_title, status, signed_at, ip_address, user_agent, channel as consent_channel
|
||||||
|
FROM consent_logs
|
||||||
|
WHERE wallet_address = $1
|
||||||
|
AND status = 'granted'
|
||||||
|
AND (user_id IS NULL OR user_id = $2)
|
||||||
|
`, [guestWalletAddress, userId]);
|
||||||
|
|
||||||
|
if (guestConsents.length > 0) {
|
||||||
|
logger.info(`[UniversalGuestService] Найдено ${guestConsents.length} согласий для переноса`);
|
||||||
|
|
||||||
|
// Переносим согласия на пользователя
|
||||||
|
// Обновляем wallet_address на нормализованный адрес кошелька пользователя, если он есть
|
||||||
|
const identityService = require('./identity-service');
|
||||||
|
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
|
||||||
|
const normalizedWalletAddress = walletIdentity?.provider_id || null;
|
||||||
|
|
||||||
|
for (const consent of guestConsents) {
|
||||||
|
await db.getQuery()(`
|
||||||
|
UPDATE consent_logs
|
||||||
|
SET user_id = $1,
|
||||||
|
wallet_address = COALESCE($2, wallet_address),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
`, [userId, normalizedWalletAddress, consent.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[UniversalGuestService] Перенесено ${guestConsents.length} согласий на user ${userId}`);
|
||||||
|
}
|
||||||
|
} catch (consentError) {
|
||||||
|
// Не критично, если не удалось перенести согласия - просто логируем
|
||||||
|
logger.warn(`[UniversalGuestService] Ошибка переноса согласий (не критично):`, consentError);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`[UniversalGuestService] Миграция завершена: ${migrated} перенесено, ${skipped} пропущено`);
|
logger.info(`[UniversalGuestService] Миграция завершена: ${migrated} перенесено, ${skipped} пропущено`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,28 +33,47 @@ const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
|
|||||||
class AuthService {
|
class AuthService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
// Проверка подписи
|
// Проверка подписи SIWE
|
||||||
async verifySignature(message, signature, address) {
|
// Используем SiweMessage для правильной проверки SIWE подписей
|
||||||
|
async verifySignature(siweMessage, signature, address) {
|
||||||
try {
|
try {
|
||||||
if (!message || !signature || !address) return false;
|
if (!siweMessage || !signature || !address) return false;
|
||||||
|
|
||||||
// Нормализуем входящий адрес
|
// Нормализуем входящий адрес
|
||||||
const normalizedAddress = ethers.getAddress(address);
|
const normalizedAddress = ethers.getAddress(address);
|
||||||
|
|
||||||
// Восстанавливаем адрес из подписи
|
// Используем SiweMessage.verify() для правильной проверки SIWE подписи
|
||||||
const recoveredAddress = ethers.verifyMessage(message, signature);
|
const { SiweMessage } = require('siwe');
|
||||||
|
|
||||||
|
// Если siweMessage уже является объектом SiweMessage, используем его напрямую
|
||||||
|
// Если это строка, парсим её
|
||||||
|
let message;
|
||||||
|
if (typeof siweMessage === 'string') {
|
||||||
|
message = new SiweMessage(siweMessage);
|
||||||
|
} else {
|
||||||
|
message = siweMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем подпись через SiweMessage.verify()
|
||||||
|
const { success, data } = await message.verify({ signature });
|
||||||
|
|
||||||
// Логируем для отладки
|
// Логируем для отладки
|
||||||
logger.info(`[verifySignature] Message: ${message}`);
|
logger.info(`[verifySignature] SIWE verification result: ${success}`);
|
||||||
|
if (data) {
|
||||||
|
logger.info(`[verifySignature] Verified address: ${data.address}`);
|
||||||
|
logger.info(`[verifySignature] Expected address: ${normalizedAddress}`);
|
||||||
|
logger.info(`[verifySignature] Addresses match: ${ethers.getAddress(data.address) === normalizedAddress}`);
|
||||||
|
}
|
||||||
logger.info(`[verifySignature] Signature: ${signature}`);
|
logger.info(`[verifySignature] Signature: ${signature}`);
|
||||||
logger.info(`[verifySignature] Expected address: ${normalizedAddress}`);
|
|
||||||
logger.info(`[verifySignature] Recovered address: ${recoveredAddress}`);
|
if (!success) {
|
||||||
logger.info(`[verifySignature] Addresses match: ${ethers.getAddress(recoveredAddress) === normalizedAddress}`);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Сравниваем нормализованные адреса
|
// Сравниваем нормализованные адреса
|
||||||
return ethers.getAddress(recoveredAddress) === normalizedAddress;
|
return data && ethers.getAddress(data.address) === normalizedAddress;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in signature verification:', error);
|
logger.error('Error in SIWE signature verification:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
263
backend/services/consentService.js
Normal file
263
backend/services/consentService.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This software is proprietary and confidential.
|
||||||
|
* Unauthorized copying, modification, or distribution is prohibited.
|
||||||
|
*
|
||||||
|
* For licensing inquiries: info@hb3-accelerator.com
|
||||||
|
* Website: https://hb3-accelerator.com
|
||||||
|
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = require('../db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Маппинг названий документов на типы согласий
|
||||||
|
const DOCUMENT_CONSENT_MAP = {
|
||||||
|
'Политика конфиденциальности': 'privacy_policy',
|
||||||
|
'Права субъектов персональных данных и отзыв согласия': 'personal_data',
|
||||||
|
'Согласие на использование файлов cookie': 'cookies',
|
||||||
|
'Согласие на обработку персональных данных': 'personal_data_processing'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить согласия пользователя или гостя
|
||||||
|
* @param {Object} params - Параметры проверки
|
||||||
|
* @param {number|null} params.userId - ID пользователя (если авторизован)
|
||||||
|
* @param {string|null} params.walletAddress - Адрес кошелька или guest_ID
|
||||||
|
* @returns {Promise<Object>} - Результат проверки с информацией о недостающих согласиях
|
||||||
|
*/
|
||||||
|
async function checkConsents({ userId = null, walletAddress = null }) {
|
||||||
|
try {
|
||||||
|
const requiredConsentTypes = Object.values(DOCUMENT_CONSENT_MAP);
|
||||||
|
|
||||||
|
// Строим запрос в зависимости от наличия userId
|
||||||
|
let consentCheckQuery;
|
||||||
|
let queryParams;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
// Для авторизованного пользователя
|
||||||
|
consentCheckQuery = `
|
||||||
|
SELECT consent_type, COUNT(*) as count
|
||||||
|
FROM consent_logs
|
||||||
|
WHERE status = 'granted'
|
||||||
|
AND (user_id = $1 OR wallet_address = $2)
|
||||||
|
AND consent_type = ANY($3)
|
||||||
|
GROUP BY consent_type
|
||||||
|
`;
|
||||||
|
queryParams = [userId, walletAddress, requiredConsentTypes];
|
||||||
|
} else if (walletAddress) {
|
||||||
|
// Для гостя
|
||||||
|
consentCheckQuery = `
|
||||||
|
SELECT consent_type, COUNT(*) as count
|
||||||
|
FROM consent_logs
|
||||||
|
WHERE wallet_address = $1
|
||||||
|
AND status = 'granted'
|
||||||
|
AND consent_type = ANY($2)
|
||||||
|
GROUP BY consent_type
|
||||||
|
`;
|
||||||
|
queryParams = [walletAddress, requiredConsentTypes];
|
||||||
|
} else {
|
||||||
|
// Если нет ни userId, ни walletAddress - все согласия отсутствуют
|
||||||
|
return {
|
||||||
|
needsConsent: true,
|
||||||
|
missingConsents: requiredConsentTypes,
|
||||||
|
grantedConsents: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const consentResult = await db.getQuery()(consentCheckQuery, queryParams);
|
||||||
|
const grantedConsentTypes = consentResult.rows.map(r => r.consent_type);
|
||||||
|
const missingConsents = requiredConsentTypes.filter(type => !grantedConsentTypes.includes(type));
|
||||||
|
const needsConsent = missingConsents.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
needsConsent,
|
||||||
|
missingConsents,
|
||||||
|
grantedConsents: grantedConsentTypes
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[ConsentService] Ошибка проверки согласий:', error);
|
||||||
|
// В случае ошибки считаем, что согласия нужны для безопасности
|
||||||
|
return {
|
||||||
|
needsConsent: true,
|
||||||
|
missingConsents: Object.values(DOCUMENT_CONSENT_MAP),
|
||||||
|
grantedConsents: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить список документов для подписания
|
||||||
|
* @param {Array<string>} missingConsents - Типы недостающих согласий
|
||||||
|
* @returns {Promise<Array>} - Массив документов с информацией
|
||||||
|
*/
|
||||||
|
async function getConsentDocuments(missingConsents = []) {
|
||||||
|
try {
|
||||||
|
// Определяем, какие документы нужно подписать (по недостающим типам согласий)
|
||||||
|
const documentsToShow = Object.entries(DOCUMENT_CONSENT_MAP)
|
||||||
|
.filter(([title, consentType]) => missingConsents.includes(consentType))
|
||||||
|
.map(([title]) => title);
|
||||||
|
|
||||||
|
if (documentsToShow.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем список документов для подписания
|
||||||
|
const { rows: documents } = await db.getQuery()(`
|
||||||
|
SELECT id, title, summary
|
||||||
|
FROM admin_pages_simple
|
||||||
|
WHERE status = 'published'
|
||||||
|
AND visibility = 'public'
|
||||||
|
AND title = ANY($1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [documentsToShow]);
|
||||||
|
|
||||||
|
return documents.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
summary: doc.summary,
|
||||||
|
consentType: DOCUMENT_CONSENT_MAP[doc.title],
|
||||||
|
url: `/content/published/${doc.id}`
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[ConsentService] Ошибка получения документов:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сформировать системное сообщение о необходимости согласия
|
||||||
|
* @param {Object} params - Параметры
|
||||||
|
* @param {string} params.channel - Канал (web/telegram/email)
|
||||||
|
* @param {Array} params.missingConsents - Типы недостающих согласий
|
||||||
|
* @param {Array} params.consentDocuments - Документы для подписания
|
||||||
|
* @param {string} params.baseUrl - Базовый URL сайта (для формирования ссылок)
|
||||||
|
* @returns {Object} - Системное сообщение, отформатированное для канала
|
||||||
|
*/
|
||||||
|
function formatConsentMessage({ channel = 'web', missingConsents = [], consentDocuments = [], baseUrl = 'http://localhost:9000' }) {
|
||||||
|
const baseMessage = 'Для полноценного использования сервиса необходимо предоставить согласие на обработку персональных данных.';
|
||||||
|
const autoConsentMessage = '\n\n⚠️ Внимание: При ответе на это сообщение вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.';
|
||||||
|
|
||||||
|
switch (channel) {
|
||||||
|
case 'web':
|
||||||
|
// Для веб-чата возвращаем структурированные данные
|
||||||
|
return {
|
||||||
|
content: baseMessage + autoConsentMessage,
|
||||||
|
consentRequired: true,
|
||||||
|
missingConsents,
|
||||||
|
consentDocuments,
|
||||||
|
autoConsentOnReply: true, // Флаг для автоматического подписания при ответе
|
||||||
|
format: 'structured'
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'telegram':
|
||||||
|
// Для Telegram форматируем как текст с ссылками
|
||||||
|
let telegramMessage = `${baseMessage}\n\nДля продолжения работы ознакомьтесь со следующими документами:\n\n`;
|
||||||
|
|
||||||
|
consentDocuments.forEach((doc, index) => {
|
||||||
|
telegramMessage += `${index + 1}. ${doc.title}\n`;
|
||||||
|
if (doc.summary) {
|
||||||
|
telegramMessage += ` ${doc.summary}\n`;
|
||||||
|
}
|
||||||
|
telegramMessage += ` ${baseUrl}${doc.url}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
telegramMessage += `⚠️ Внимание: При ответе на это сообщение вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: telegramMessage,
|
||||||
|
consentRequired: true,
|
||||||
|
missingConsents,
|
||||||
|
consentDocuments,
|
||||||
|
autoConsentOnReply: true,
|
||||||
|
format: 'text'
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
// Для email форматируем как HTML
|
||||||
|
let emailHtml = `<p>${baseMessage}</p>`;
|
||||||
|
emailHtml += '<p>Для продолжения работы ознакомьтесь со следующими документами:</p>';
|
||||||
|
emailHtml += '<ul>';
|
||||||
|
|
||||||
|
consentDocuments.forEach((doc) => {
|
||||||
|
emailHtml += `<li><strong>${doc.title}</strong><br>`;
|
||||||
|
if (doc.summary) {
|
||||||
|
emailHtml += `${doc.summary}<br>`;
|
||||||
|
}
|
||||||
|
emailHtml += `<a href="${baseUrl}${doc.url}">Открыть документ</a></li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
emailHtml += '</ul>';
|
||||||
|
emailHtml += `<p><strong>⚠️ Внимание:</strong> При ответе на это письмо вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.</p>`;
|
||||||
|
emailHtml += `<p><a href="${baseUrl}/consent">Также вы можете подписать документы на сайте</a></p>`;
|
||||||
|
|
||||||
|
const textContent = baseMessage + '\n\n' +
|
||||||
|
consentDocuments.map(doc => `${doc.title}: ${baseUrl}${doc.url}`).join('\n') +
|
||||||
|
'\n\n⚠️ Внимание: При ответе на это письмо вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.';
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: emailHtml,
|
||||||
|
textContent: textContent,
|
||||||
|
consentRequired: true,
|
||||||
|
missingConsents,
|
||||||
|
consentDocuments,
|
||||||
|
autoConsentOnReply: true,
|
||||||
|
format: 'html'
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
content: baseMessage + autoConsentMessage,
|
||||||
|
consentRequired: true,
|
||||||
|
missingConsents,
|
||||||
|
consentDocuments,
|
||||||
|
autoConsentOnReply: true,
|
||||||
|
format: 'text'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить полную информацию о согласиях и сформировать системное сообщение
|
||||||
|
* @param {Object} params - Параметры
|
||||||
|
* @param {number|null} params.userId - ID пользователя
|
||||||
|
* @param {string|null} params.walletAddress - Адрес кошелька или guest_ID
|
||||||
|
* @param {string} params.channel - Канал (web/telegram/email)
|
||||||
|
* @param {string} params.baseUrl - Базовый URL сайта
|
||||||
|
* @returns {Promise<Object|null>} - Системное сообщение или null, если согласия есть
|
||||||
|
*/
|
||||||
|
async function getConsentSystemMessage({ userId = null, walletAddress = null, channel = 'web', baseUrl = 'http://localhost:9000' }) {
|
||||||
|
try {
|
||||||
|
// Проверяем согласия
|
||||||
|
const consentCheck = await checkConsents({ userId, walletAddress });
|
||||||
|
|
||||||
|
if (!consentCheck.needsConsent) {
|
||||||
|
return null; // Все согласия есть
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем документы для подписания
|
||||||
|
const consentDocuments = await getConsentDocuments(consentCheck.missingConsents);
|
||||||
|
|
||||||
|
// Формируем системное сообщение для канала
|
||||||
|
return formatConsentMessage({
|
||||||
|
channel,
|
||||||
|
missingConsents: consentCheck.missingConsents,
|
||||||
|
consentDocuments,
|
||||||
|
baseUrl
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[ConsentService] Ошибка формирования системного сообщения:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkConsents,
|
||||||
|
getConsentDocuments,
|
||||||
|
formatConsentMessage,
|
||||||
|
getConsentSystemMessage,
|
||||||
|
DOCUMENT_CONSENT_MAP
|
||||||
|
};
|
||||||
|
|
||||||
@@ -297,7 +297,22 @@ class EmailBot {
|
|||||||
|
|
||||||
const messageData = await this.extractMessageData(parsed, messageId, uid);
|
const messageData = await this.extractMessageData(parsed, messageId, uid);
|
||||||
if (messageData && this.messageProcessor) {
|
if (messageData && this.messageProcessor) {
|
||||||
await this.messageProcessor(messageData);
|
// Обрабатываем сообщение через унифицированный процессор
|
||||||
|
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
|
||||||
|
const result = await this.messageProcessor(messageData);
|
||||||
|
|
||||||
|
// Если есть ответ ИИ с информацией о согласиях, отправляем email
|
||||||
|
if (result && result.success && result.aiResponse) {
|
||||||
|
const fromEmail = parsed.from?.value?.[0]?.address;
|
||||||
|
if (fromEmail) {
|
||||||
|
// Ответ ИИ уже содержит системное сообщение о согласиях (если нужно)
|
||||||
|
await this.sendEmail(
|
||||||
|
fromEmail,
|
||||||
|
'Ответ на ваше сообщение',
|
||||||
|
result.aiResponse.response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processedCount++;
|
processedCount++;
|
||||||
@@ -466,6 +481,40 @@ class EmailBot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка email с HTML содержимым
|
||||||
|
* @param {string} to - Email получателя
|
||||||
|
* @param {string} subject - Тема письма
|
||||||
|
* @param {string} text - Текстовая версия
|
||||||
|
* @param {string} html - HTML версия
|
||||||
|
*/
|
||||||
|
async sendEmailWithHtml(to, subject, text, html) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(to)) {
|
||||||
|
throw new Error(`Неверный формат email адреса: ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mailOptions = {
|
||||||
|
from: this.settings.from_email,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.transporter.sendMail(mailOptions);
|
||||||
|
this.transporter.close();
|
||||||
|
|
||||||
|
logger.info(`[EmailBot] Email с HTML отправлен успешно: ${to}`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[EmailBot] Ошибка отправки email с HTML:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправка кода верификации
|
* Отправка кода верификации
|
||||||
* @param {string} email - Email получателя
|
* @param {string} email - Email получателя
|
||||||
|
|||||||
@@ -300,26 +300,48 @@ class EncryptedDataService {
|
|||||||
|
|
||||||
const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`;
|
const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`;
|
||||||
|
|
||||||
// Собираем параметры в правильном порядке: сначала для encrypted, потом для unencrypted
|
// Собираем параметры в правильном порядке по номерам из плейсхолдеров
|
||||||
const paramsArray = [];
|
const paramMap = new Map(); // номер параметра -> значение
|
||||||
if (hasEncryptedFields) paramsArray.push(this.encryptionKey);
|
|
||||||
|
|
||||||
// Добавляем параметры для encrypted колонок
|
if (hasEncryptedFields) {
|
||||||
for (const key of Object.keys(encryptedData)) {
|
paramMap.set(1, this.encryptionKey); // $1 - ключ шифрования
|
||||||
const originalKey = key.replace('_encrypted', '');
|
}
|
||||||
if (filteredData[originalKey] !== undefined) {
|
|
||||||
paramsArray.push(filteredData[originalKey]);
|
// Проходим по колонкам в порядке allData и добавляем соответствующие значения
|
||||||
} else if (filteredData[originalKey + '_unencrypted'] !== undefined) {
|
for (const key of Object.keys(allData)) {
|
||||||
paramsArray.push(filteredData[originalKey + '_unencrypted']);
|
const placeholder = allData[key].toString();
|
||||||
|
// Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text)
|
||||||
|
const paramMatches = placeholder.match(/\$(\d+)/g);
|
||||||
|
if (paramMatches) {
|
||||||
|
// Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.)
|
||||||
|
// Для незашифрованных - первый параметр ($2, $3 и т.д.)
|
||||||
|
if (encryptedData[key]) {
|
||||||
|
// Это зашифрованная колонка - берем второй параметр (первый это $1 - ключ шифрования)
|
||||||
|
const originalKey = key.replace('_encrypted', '');
|
||||||
|
if (filteredData[originalKey] !== undefined && paramMatches.length > 0) {
|
||||||
|
// Последний параметр это значение для шифрования
|
||||||
|
const valueParam = paramMatches[paramMatches.length - 1];
|
||||||
|
const paramNum = parseInt(valueParam.substring(1));
|
||||||
|
paramMap.set(paramNum, filteredData[originalKey]);
|
||||||
|
}
|
||||||
|
} else if (unencryptedData[key]) {
|
||||||
|
// Это незашифрованная колонка - берем параметр из плейсхолдера
|
||||||
|
const valueParam = paramMatches[0];
|
||||||
|
const paramNum = parseInt(valueParam.substring(1));
|
||||||
|
paramMap.set(paramNum, filteredData[key]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем параметры для unencrypted колонок
|
// Создаем массив параметров в правильном порядке (от $1 до максимального номера)
|
||||||
for (const key of Object.keys(unencryptedData)) {
|
const maxParamNum = Math.max(...Array.from(paramMap.keys()));
|
||||||
paramsArray.push(filteredData[key + '_unencrypted'] || filteredData[key]);
|
const params = [];
|
||||||
|
for (let i = 1; i <= maxParamNum; i++) {
|
||||||
|
if (!paramMap.has(i)) {
|
||||||
|
throw new Error(`Отсутствует параметр $${i} для запроса`);
|
||||||
|
}
|
||||||
|
params.push(paramMap.get(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = paramsArray;
|
|
||||||
|
|
||||||
console.log(`🔍 Выполняем INSERT запрос:`, query);
|
console.log(`🔍 Выполняем INSERT запрос:`, query);
|
||||||
console.log(`🔍 Параметры:`, params);
|
console.log(`🔍 Параметры:`, params);
|
||||||
|
|||||||
@@ -114,6 +114,46 @@ class SessionService {
|
|||||||
const identifier = `web:${guestId}`; // Старые гости всегда из web
|
const identifier = `web:${guestId}`; // Старые гости всегда из web
|
||||||
await universalGuestService.migrateToUser(identifier, userId);
|
await universalGuestService.migrateToUser(identifier, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// После миграции сообщений переносим согласия по всем гостевых идентификаторам
|
||||||
|
// Это нужно на случай, если согласия были связаны с гостевой сессией
|
||||||
|
try {
|
||||||
|
const encryptionUtils = require('../utils/encryptionUtils');
|
||||||
|
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||||
|
|
||||||
|
// Получаем wallet адрес пользователя для обновления согласий
|
||||||
|
const identityService = require('./identity-service');
|
||||||
|
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
|
||||||
|
const normalizedWalletAddress = walletIdentity?.provider_id || null;
|
||||||
|
|
||||||
|
for (const guestId of guestIdsToProcess) {
|
||||||
|
// Ищем согласия по гостевому идентификатору в формате "guest_${guestId}"
|
||||||
|
const guestWalletAddress = `guest_${guestId}`;
|
||||||
|
|
||||||
|
const { rows: guestConsents } = await db.getQuery()(`
|
||||||
|
SELECT id FROM consent_logs
|
||||||
|
WHERE wallet_address = $1
|
||||||
|
AND status = 'granted'
|
||||||
|
AND (user_id IS NULL OR user_id = $2)
|
||||||
|
`, [guestWalletAddress, userId]);
|
||||||
|
|
||||||
|
if (guestConsents.length > 0) {
|
||||||
|
// Переносим согласия на пользователя и обновляем wallet_address
|
||||||
|
await db.getQuery()(`
|
||||||
|
UPDATE consent_logs
|
||||||
|
SET user_id = $1,
|
||||||
|
wallet_address = COALESCE($2, wallet_address),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ANY($3)
|
||||||
|
`, [userId, normalizedWalletAddress, guestConsents.map(c => c.id)]);
|
||||||
|
|
||||||
|
logger.info(`[SessionService] Перенесено ${guestConsents.length} согласий для guest ${guestId} → user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (consentError) {
|
||||||
|
// Не критично, если не удалось перенести согласия - просто логируем
|
||||||
|
logger.warn(`[SessionService] Ошибка переноса согласий (не критично):`, consentError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, processedCount: guestIdsToProcess.size };
|
return { success: true, processedCount: guestIdsToProcess.size };
|
||||||
|
|||||||
@@ -382,9 +382,11 @@ class TelegramBot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обрабатываем сообщение через унифицированный процессор
|
// Обрабатываем сообщение через унифицированный процессор
|
||||||
|
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
|
||||||
const result = await messageProcessor(messageData);
|
const result = await messageProcessor(messageData);
|
||||||
|
|
||||||
// Отправляем ответ пользователю
|
// Отправляем ответ пользователю
|
||||||
|
// Системное сообщение о согласиях уже включено в ответ ИИ (если нужно)
|
||||||
if (result.success && result.aiResponse) {
|
if (result.success && result.aiResponse) {
|
||||||
await ctx.reply(result.aiResponse.response);
|
await ctx.reply(result.aiResponse.response);
|
||||||
} else if (result.success) {
|
} else if (result.success) {
|
||||||
|
|||||||
@@ -183,6 +183,80 @@ async function processMessage(messageData) {
|
|||||||
|
|
||||||
const conversationId = conversation.id;
|
const conversationId = conversation.id;
|
||||||
|
|
||||||
|
// Получаем ключ шифрования (будет использоваться далее)
|
||||||
|
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||||
|
|
||||||
|
// 5.5. Проверяем, нужно ли автоматически подписать согласие при ответе
|
||||||
|
// Ищем последнее сообщение от ассистента или системное сообщение с согласием
|
||||||
|
const consentService = require('./consentService');
|
||||||
|
|
||||||
|
const { rows: lastMessages } = await db.getQuery()(
|
||||||
|
`SELECT
|
||||||
|
decrypt_text(role_encrypted, $2) as role,
|
||||||
|
decrypt_text(content_encrypted, $2) as content,
|
||||||
|
message_type
|
||||||
|
FROM messages
|
||||||
|
WHERE conversation_id = $1
|
||||||
|
AND user_id = $3
|
||||||
|
AND (
|
||||||
|
decrypt_text(role_encrypted, $2) = 'assistant'
|
||||||
|
OR message_type = 'system_consent'
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[conversationId, encryptionKey, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Если последнее сообщение было от ассистента, проверяем наличие системного сообщения о согласиях
|
||||||
|
if (lastMessages.length > 0) {
|
||||||
|
// Проверяем согласия пользователя
|
||||||
|
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
|
||||||
|
const consentCheck = await consentService.checkConsents({
|
||||||
|
userId,
|
||||||
|
walletAddress: walletIdentity?.provider_id || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если согласия нужны, но пользователь отвечает на сообщение, автоматически подписываем
|
||||||
|
if (consentCheck.needsConsent) {
|
||||||
|
logger.info(`[UnifiedMessageProcessor] Автоматическое подписание согласий при ответе пользователя ${userId}`);
|
||||||
|
|
||||||
|
// Получаем документы для подписания
|
||||||
|
const consentDocuments = await consentService.getConsentDocuments(consentCheck.missingConsents);
|
||||||
|
const documentIds = consentDocuments.map(doc => doc.id);
|
||||||
|
const consentTypes = consentDocuments.map(doc => doc.consentType).filter(type => type);
|
||||||
|
|
||||||
|
// Автоматически подписываем согласие
|
||||||
|
if (documentIds.length > 0 && consentTypes.length > 0) {
|
||||||
|
const consentRoutes = require('../routes/consent');
|
||||||
|
// Вызываем логику подписания напрямую через сервис или API
|
||||||
|
try {
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at)
|
||||||
|
SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW()
|
||||||
|
ON CONFLICT (user_id, consent_type, document_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
status = 'granted',
|
||||||
|
signed_at = NOW(),
|
||||||
|
revoked_at = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
walletIdentity?.provider_id || null,
|
||||||
|
documentIds,
|
||||||
|
consentDocuments.map(doc => doc.title),
|
||||||
|
consentTypes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`);
|
||||||
|
} catch (consentError) {
|
||||||
|
logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError);
|
||||||
|
// Не блокируем обработку сообщения при ошибке подписания
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Обработка вложений
|
// 6. Обработка вложений
|
||||||
let attachment_filename = null;
|
let attachment_filename = null;
|
||||||
let attachment_mimetype = null;
|
let attachment_mimetype = null;
|
||||||
@@ -198,7 +272,7 @@ async function processMessage(messageData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Сохраняем входящее сообщение пользователя
|
// 7. Сохраняем входящее сообщение пользователя
|
||||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
// encryptionKey уже объявлен выше
|
||||||
|
|
||||||
const { rows } = await db.getQuery()(
|
const { rows } = await db.getQuery()(
|
||||||
`INSERT INTO messages (
|
`INSERT INTO messages (
|
||||||
@@ -293,7 +367,31 @@ async function processMessage(messageData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (aiResponse && aiResponse.success && aiResponse.response) {
|
if (aiResponse && aiResponse.success && aiResponse.response) {
|
||||||
// Сохраняем ответ AI
|
// Проверяем согласия и добавляем системное сообщение к ответу ИИ
|
||||||
|
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
|
||||||
|
const consentSystemMessage = await consentService.getConsentSystemMessage({
|
||||||
|
userId,
|
||||||
|
walletAddress: walletIdentity?.provider_id || null,
|
||||||
|
channel: channel === 'web' ? 'web' : channel,
|
||||||
|
baseUrl: process.env.BASE_URL || 'http://localhost:9000'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Формируем финальный ответ ИИ с системным сообщением, если нужно
|
||||||
|
let finalAiResponse = aiResponse.response;
|
||||||
|
if (consentSystemMessage && consentSystemMessage.consentRequired) {
|
||||||
|
// Добавляем системное сообщение к ответу ИИ
|
||||||
|
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
|
||||||
|
|
||||||
|
// Сохраняем информацию о согласиях в метаданные ответа
|
||||||
|
aiResponse.consentInfo = {
|
||||||
|
consentRequired: true,
|
||||||
|
missingConsents: consentSystemMessage.missingConsents,
|
||||||
|
consentDocuments: consentSystemMessage.consentDocuments,
|
||||||
|
autoConsentOnReply: consentSystemMessage.autoConsentOnReply
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем ответ AI с добавленным системным сообщением
|
||||||
const { rows: aiMessageRows } = await db.getQuery()(
|
const { rows: aiMessageRows } = await db.getQuery()(
|
||||||
`INSERT INTO messages (
|
`INSERT INTO messages (
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -322,7 +420,7 @@ async function processMessage(messageData) {
|
|||||||
conversationId,
|
conversationId,
|
||||||
userId, // sender_id
|
userId, // sender_id
|
||||||
'assistant',
|
'assistant',
|
||||||
aiResponse.response,
|
finalAiResponse,
|
||||||
channel,
|
channel,
|
||||||
'assistant',
|
'assistant',
|
||||||
'outgoing',
|
'outgoing',
|
||||||
@@ -353,17 +451,27 @@ async function processMessage(messageData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 11. Возвращаем результат
|
// 11. Возвращаем результат
|
||||||
return {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
userMessageId,
|
userMessageId,
|
||||||
conversationId,
|
conversationId,
|
||||||
aiResponse: aiResponse && aiResponse.success ? {
|
aiResponse: aiResponse && aiResponse.success ? {
|
||||||
response: aiResponse.response,
|
response: finalAiResponse || aiResponse.response,
|
||||||
ragData: aiResponse.ragData
|
ragData: aiResponse.ragData
|
||||||
} : null,
|
} : null,
|
||||||
noAiResponse: !shouldGenerateAi
|
noAiResponse: !shouldGenerateAi
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Если есть информация о согласиях, добавляем её в результат
|
||||||
|
if (aiResponse && aiResponse.success && aiResponse.consentInfo) {
|
||||||
|
result.consentRequired = aiResponse.consentInfo.consentRequired;
|
||||||
|
result.missingConsents = aiResponse.consentInfo.missingConsents;
|
||||||
|
result.consentDocuments = aiResponse.consentInfo.consentDocuments;
|
||||||
|
result.autoConsentOnReply = aiResponse.consentInfo.autoConsentOnReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[UnifiedMessageProcessor] Ошибка обработки сообщения:', error);
|
logger.error('[UnifiedMessageProcessor] Ошибка обработки сообщения:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
:message="message"
|
:message="message"
|
||||||
:isPrivateChat="isPrivateChat"
|
:isPrivateChat="isPrivateChat"
|
||||||
:currentUserId="currentUserId"
|
:currentUserId="currentUserId"
|
||||||
|
@consent-granted="handleConsentGranted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -148,6 +150,7 @@ const emit = defineEmits([
|
|||||||
'send-message',
|
'send-message',
|
||||||
'load-more', // Событие для загрузки старых сообщений
|
'load-more', // Событие для загрузки старых сообщений
|
||||||
'ai-reply',
|
'ai-reply',
|
||||||
|
'remove-consent-messages', // Событие для удаления системных сообщений о согласиях
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const messagesContainer = ref(null);
|
const messagesContainer = ref(null);
|
||||||
@@ -155,6 +158,11 @@ const messageInputRef = ref(null);
|
|||||||
const chatInputRef = ref(null); // Ref для chat-input
|
const chatInputRef = ref(null); // Ref для chat-input
|
||||||
const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее)
|
const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее)
|
||||||
|
|
||||||
|
function handleConsentGranted(messageId) {
|
||||||
|
// После подписания удаляем системное сообщение о необходимости согласия
|
||||||
|
emit('remove-consent-messages', [messageId]);
|
||||||
|
}
|
||||||
|
|
||||||
// Локальное состояние для предпросмотра, синхронизированное с props.attachments
|
// Локальное состояние для предпросмотра, синхронизированное с props.attachments
|
||||||
const localAttachments = ref([...props.attachments]);
|
const localAttachments = ref([...props.attachments]);
|
||||||
watch(() => props.attachments, (newVal) => {
|
watch(() => props.attachments, (newVal) => {
|
||||||
|
|||||||
341
frontend/src/components/ConsentModal.vue
Normal file
341
frontend/src/components/ConsentModal.vue
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is proprietary and confidential.
|
||||||
|
Unauthorized copying, modification, or distribution is prohibited.
|
||||||
|
|
||||||
|
For licensing inquiries: info@hb3-accelerator.com
|
||||||
|
Website: https://hb3-accelerator.com
|
||||||
|
GitHub: https://github.com/VC-HB3-Accelerator
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="consent-modal-overlay" @click.self="close">
|
||||||
|
<div class="consent-modal">
|
||||||
|
<div class="consent-modal-header">
|
||||||
|
<h2>Подписание документов</h2>
|
||||||
|
<button class="close-btn" @click="close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="consent-modal-content">
|
||||||
|
<p class="consent-description">
|
||||||
|
Для полноценного использования сервиса необходимо ознакомиться и подписать следующие документы:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<p>Загрузка документов...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="documents.length === 0" class="empty-state">
|
||||||
|
<p>Документы не найдены</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="documents-list">
|
||||||
|
<div v-for="doc in documents" :key="doc.id" class="document-item">
|
||||||
|
<label class="document-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="doc.id"
|
||||||
|
v-model="selectedDocuments"
|
||||||
|
class="checkbox-input"
|
||||||
|
/>
|
||||||
|
<div class="document-info">
|
||||||
|
<h3 class="document-title">{{ doc.title }}</h3>
|
||||||
|
<p v-if="doc.summary" class="document-summary">{{ doc.summary }}</p>
|
||||||
|
<a
|
||||||
|
:href="`/content/published/${doc.id}`"
|
||||||
|
target="_blank"
|
||||||
|
class="document-link"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
Открыть документ →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="consent-modal-footer">
|
||||||
|
<button class="btn-secondary" @click="close">Отмена</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
@click="submitConsent"
|
||||||
|
:disabled="selectedDocuments.length === 0 || submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Подписание...' : 'Подписать' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue';
|
||||||
|
import api from '../api/axios';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
missingConsents: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'consent-granted']);
|
||||||
|
|
||||||
|
const documents = ref([]);
|
||||||
|
const selectedDocuments = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
// Загружаем документы для подписания
|
||||||
|
async function loadDocuments() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.get('/consent/documents');
|
||||||
|
documents.value = response.data || [];
|
||||||
|
|
||||||
|
// Автоматически выбираем все документы
|
||||||
|
selectedDocuments.value = documents.value.map(doc => doc.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки документов:', error);
|
||||||
|
documents.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем согласие
|
||||||
|
async function submitConsent() {
|
||||||
|
if (selectedDocuments.value.length === 0) return;
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
// Получаем типы согласий для выбранных документов
|
||||||
|
const consentTypes = documents.value
|
||||||
|
.filter(doc => selectedDocuments.value.includes(doc.id))
|
||||||
|
.map(doc => doc.consentType)
|
||||||
|
.filter(type => type);
|
||||||
|
|
||||||
|
await api.post('/consent/grant', {
|
||||||
|
documentIds: selectedDocuments.value,
|
||||||
|
consentTypes: consentTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('consent-granted');
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка подписания документов:', error);
|
||||||
|
alert('Ошибка при подписании документов. Попробуйте еще раз.');
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close');
|
||||||
|
selectedDocuments.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем документы при открытии модалки
|
||||||
|
watch(() => props.isOpen, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
loadDocuments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
loadDocuments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.consent-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #888;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-description {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-item {
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-item:hover {
|
||||||
|
border-color: var(--color-primary, #007bff);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-checkbox {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input {
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-primary, #333);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-link {
|
||||||
|
color: var(--color-primary, #007bff);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-primary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary, #007bff);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-dark, #0056b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -46,8 +46,43 @@
|
|||||||
<a :href="replyLink" class="reply-link">Ответить</a>
|
<a :href="replyLink" class="reply-link">Ответить</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Блок с документами для подписания -->
|
||||||
|
<div v-if="message.consentRequired && message.consentDocuments" class="consent-documents-block">
|
||||||
|
<div v-for="doc in message.consentDocuments" :key="doc.id" class="consent-document-item">
|
||||||
|
<label class="consent-document-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="doc.id"
|
||||||
|
v-model="selectedConsentDocuments"
|
||||||
|
class="consent-checkbox"
|
||||||
|
/>
|
||||||
|
<div class="consent-document-info">
|
||||||
|
<h4 class="consent-document-title">{{ doc.title }}</h4>
|
||||||
|
<p v-if="doc.summary" class="consent-document-summary">{{ doc.summary }}</p>
|
||||||
|
<a
|
||||||
|
:href="`/content/published/${doc.id}`"
|
||||||
|
target="_blank"
|
||||||
|
class="consent-document-link"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
Открыть документ →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="consent-actions">
|
||||||
|
<button
|
||||||
|
@click="submitConsent"
|
||||||
|
class="system-btn primary"
|
||||||
|
:disabled="selectedConsentDocuments.length === 0 || isSubmittingConsent"
|
||||||
|
>
|
||||||
|
{{ isSubmittingConsent ? 'Подписание...' : 'Подписать' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Кнопки для системного сообщения -->
|
<!-- Кнопки для системного сообщения -->
|
||||||
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions">
|
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail) && !message.consentRequired" class="system-actions">
|
||||||
<button v-if="message.telegramBotUrl" @click="openTelegram(message.telegramBotUrl)" class="system-btn">Перейти в Telegram-бот</button>
|
<button v-if="message.telegramBotUrl" @click="openTelegram(message.telegramBotUrl)" class="system-btn">Перейти в Telegram-бот</button>
|
||||||
<button v-if="message.supportEmail" @click="copyEmail(message.supportEmail)" class="system-btn">Скопировать email</button>
|
<button v-if="message.supportEmail" @click="copyEmail(message.supportEmail)" class="system-btn">Скопировать email</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +152,48 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['consent-granted']);
|
||||||
|
|
||||||
|
// Состояние для выбранных документов и отправки согласия
|
||||||
|
const selectedConsentDocuments = ref([]);
|
||||||
|
const isSubmittingConsent = ref(false);
|
||||||
|
|
||||||
|
// Инициализируем выбранные документы при монтировании, если есть документы
|
||||||
|
watch(() => props.message.consentDocuments, (docs) => {
|
||||||
|
if (docs && Array.isArray(docs) && docs.length > 0) {
|
||||||
|
// Автоматически выбираем все документы
|
||||||
|
selectedConsentDocuments.value = docs.map(doc => doc.id);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Функция подписания документов
|
||||||
|
async function submitConsent() {
|
||||||
|
if (selectedConsentDocuments.value.length === 0 || isSubmittingConsent.value) return;
|
||||||
|
|
||||||
|
isSubmittingConsent.value = true;
|
||||||
|
try {
|
||||||
|
const api = (await import('../api/axios')).default;
|
||||||
|
const documents = props.message.consentDocuments || [];
|
||||||
|
const consentTypes = documents
|
||||||
|
.filter(doc => selectedConsentDocuments.value.includes(doc.id))
|
||||||
|
.map(doc => doc.consentType)
|
||||||
|
.filter(type => type);
|
||||||
|
|
||||||
|
await api.post('/consent/grant', {
|
||||||
|
documentIds: selectedConsentDocuments.value,
|
||||||
|
consentTypes: consentTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Уведомляем родительский компонент об успешном подписании
|
||||||
|
emit('consent-granted', props.message.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка подписания документов:', error);
|
||||||
|
alert('Ошибка при подписании документов. Попробуйте еще раз.');
|
||||||
|
} finally {
|
||||||
|
isSubmittingConsent.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Простая функция для определения, является ли сообщение отправленным текущим пользователем
|
// Простая функция для определения, является ли сообщение отправленным текущим пользователем
|
||||||
// Используем данные из самого сообщения для определения направления
|
// Используем данные из самого сообщения для определения направления
|
||||||
const isCurrentUserMessage = computed(() => {
|
const isCurrentUserMessage = computed(() => {
|
||||||
@@ -494,6 +571,97 @@ function copyEmail(email) {
|
|||||||
.system-btn:hover {
|
.system-btn:hover {
|
||||||
background: var(--color-primary-dark, #2563eb);
|
background: var(--color-primary-dark, #2563eb);
|
||||||
}
|
}
|
||||||
|
.system-btn.primary {
|
||||||
|
background: var(--color-primary, #007bff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.system-btn.primary:hover {
|
||||||
|
background: var(--color-primary-dark, #0056b3);
|
||||||
|
}
|
||||||
|
.system-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для блока с документами для подписания */
|
||||||
|
.consent-documents-block {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-item:hover {
|
||||||
|
border-color: var(--color-primary, #007bff);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-label {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-checkbox {
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-title {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-primary, #333);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-summary {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-link {
|
||||||
|
color: var(--color-primary, #007bff);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-document-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
/* Стили для информации об отправителе в приватном чате */
|
/* Стили для информации об отправителе в приватном чате */
|
||||||
.message-sender-info {
|
.message-sender-info {
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export function useChat(auth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем ответ ИИ, если есть
|
// Добавляем ответ ИИ, если есть
|
||||||
|
// Системное сообщение о согласиях уже включено в ответ ИИ
|
||||||
if (response.data.aiResponse) {
|
if (response.data.aiResponse) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: `ai_${Date.now()}`,
|
id: `ai_${Date.now()}`,
|
||||||
@@ -323,12 +324,17 @@ export function useChat(auth) {
|
|||||||
sender_type: 'assistant',
|
sender_type: 'assistant',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
isLocal: false
|
isLocal: false,
|
||||||
|
// Добавляем информацию о согласиях, если есть
|
||||||
|
consentRequired: response.data.consentRequired || false,
|
||||||
|
missingConsents: response.data.missingConsents || [],
|
||||||
|
consentDocuments: response.data.consentDocuments || [],
|
||||||
|
autoConsentOnReply: response.data.autoConsentOnReply || false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем системное сообщение для гостя (только на клиенте, не сохраняется в истории)
|
// Добавляем системное сообщение для гостя (только если нет согласия, чтобы не дублировать)
|
||||||
if (isGuestMessage && response.data.systemMessage) {
|
if (isGuestMessage && response.data.systemMessage && !response.data.consentRequired) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: `system-${Date.now()}`,
|
id: `system-${Date.now()}`,
|
||||||
content: response.data.systemMessage,
|
content: response.data.systemMessage,
|
||||||
|
|||||||
@@ -48,12 +48,30 @@ export async function connectWithWallet() {
|
|||||||
throw new Error('Не удалось получить nonce с сервера');
|
throw new Error('Не удалось получить nonce с сервера');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем список документов для подписания
|
||||||
|
let resources = [`${window.location.origin}/api/auth/verify`];
|
||||||
|
try {
|
||||||
|
const docsResponse = await axios.get('/consent/documents');
|
||||||
|
if (docsResponse.data && docsResponse.data.length > 0) {
|
||||||
|
docsResponse.data.forEach(doc => {
|
||||||
|
resources.push(`${window.location.origin}/content/published/${doc.id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Если не удалось получить документы, продолжаем без них
|
||||||
|
console.warn('Не удалось получить список документов для подписания:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем сообщение для подписи
|
// Создаем сообщение для подписи
|
||||||
const domain = window.location.host;
|
const domain = window.location.host;
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
const statement = 'Sign in with Ethereum to the app.';
|
const statement = 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.';
|
||||||
|
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Создаем копию resources и сортируем (не мутируем исходный массив)
|
||||||
|
const sortedResources = [...resources].sort();
|
||||||
|
|
||||||
const siweMessage = new SiweMessage({
|
const siweMessage = new SiweMessage({
|
||||||
domain,
|
domain,
|
||||||
address,
|
address,
|
||||||
@@ -63,7 +81,7 @@ export async function connectWithWallet() {
|
|||||||
chainId: 1,
|
chainId: 1,
|
||||||
nonce,
|
nonce,
|
||||||
issuedAt,
|
issuedAt,
|
||||||
resources: [`${origin}/api/auth/verify`],
|
resources: sortedResources,
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = siweMessage.prepareMessage();
|
const message = siweMessage.prepareMessage();
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ export const connectWallet = async () => {
|
|||||||
|
|
||||||
// Берем первый аккаунт в списке
|
// Берем первый аккаунт в списке
|
||||||
const address = accounts[0];
|
const address = accounts[0];
|
||||||
// Нормализуем адрес (приводим к нижнему регистру для последующих сравнений)
|
// Нормализуем адрес (используем getAddress для совместимости)
|
||||||
const normalizedAddress = ethers.utils.getAddress(address);
|
// Проверяем версию ethers - если v6, используем ethers.getAddress, иначе ethers.utils.getAddress
|
||||||
|
const normalizedAddress = ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address);
|
||||||
// console.log('Normalized address:', normalizedAddress);
|
// console.log('Normalized address:', normalizedAddress);
|
||||||
|
|
||||||
// Запрашиваем nonce с сервера
|
// Запрашиваем nonce с сервера
|
||||||
@@ -60,34 +61,70 @@ export const connectWallet = async () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем провайдер Ethers
|
// Для SIWE используем personal_sign напрямую через window.ethereum
|
||||||
const provider = new ethers.providers.Web3Provider(window.ethereum);
|
// Не используем ethers signer, так как он добавляет префикс, который нарушает SIWE формат
|
||||||
const signer = provider.getSigner();
|
|
||||||
|
// Получаем список документов для подписания
|
||||||
|
let resources = [`${window.location.origin}/api/auth/verify`];
|
||||||
|
try {
|
||||||
|
const docsResponse = await axios.get('/consent/documents');
|
||||||
|
if (docsResponse.data && docsResponse.data.length > 0) {
|
||||||
|
docsResponse.data.forEach(doc => {
|
||||||
|
resources.push(`${window.location.origin}/content/published/${doc.id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Если не удалось получить документы, продолжаем без них
|
||||||
|
console.warn('Не удалось получить список документов для подписания:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем сообщение для подписи
|
// Создаем сообщение для подписи
|
||||||
const domain = window.location.host;
|
// Важно: domain должен быть hostname без протокола и порта (если порт стандартный)
|
||||||
|
const domain = window.location.hostname === 'localhost' ?
|
||||||
|
`localhost:${window.location.port}` :
|
||||||
|
window.location.hostname;
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
|
|
||||||
|
// Создаем issuedAt один раз, чтобы использовать одинаковый в сообщении и запросе
|
||||||
|
const issuedAt = new Date().toISOString();
|
||||||
|
|
||||||
// Создаем SIWE сообщение
|
// Создаем копию resources и сортируем (не мутируем исходный массив)
|
||||||
|
const sortedResources = [...resources].sort();
|
||||||
|
|
||||||
|
// Создаем SIWE сообщение с документами в resources
|
||||||
const message = new SiweMessage({
|
const message = new SiweMessage({
|
||||||
domain,
|
domain,
|
||||||
address: normalizedAddress,
|
address: normalizedAddress,
|
||||||
statement: 'Sign in with Ethereum to the app.',
|
statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.',
|
||||||
uri: origin,
|
uri: origin,
|
||||||
version: '1',
|
version: '1',
|
||||||
chainId: 1, // Ethereum mainnet
|
chainId: 1, // Ethereum mainnet
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
issuedAt: new Date().toISOString(),
|
issuedAt: issuedAt,
|
||||||
resources: [`${origin}/api/auth/verify`],
|
resources: sortedResources,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получаем строку сообщения для подписи
|
// Получаем строку сообщения для подписи
|
||||||
const messageToSign = message.prepareMessage();
|
const messageToSign = message.prepareMessage();
|
||||||
// console.log('SIWE message:', messageToSign);
|
|
||||||
|
// Логируем для отладки
|
||||||
|
console.log('🔐 [Frontend] Domain:', domain);
|
||||||
|
console.log('🔐 [Frontend] Origin:', origin);
|
||||||
|
console.log('🔐 [Frontend] Address:', normalizedAddress);
|
||||||
|
console.log('🔐 [Frontend] Nonce:', nonce);
|
||||||
|
console.log('🔐 [Frontend] IssuedAt:', issuedAt);
|
||||||
|
console.log('🔐 [Frontend] Resources:', JSON.stringify(sortedResources));
|
||||||
|
console.log('🔐 [Frontend] SIWE message to sign:', messageToSign);
|
||||||
|
console.log('🔐 [Frontend] Message length:', messageToSign.length);
|
||||||
|
|
||||||
// Запрашиваем подпись
|
// Запрашиваем подпись через personal_sign (правильный способ для SIWE)
|
||||||
// console.log('Requesting signature...');
|
// personal_sign подписывает сообщение С префиксом "\x19Ethereum Signed Message:\n"
|
||||||
const signature = await signer.signMessage(messageToSign);
|
// ethers.verifyMessage() также добавляет этот префикс, поэтому они совместимы
|
||||||
|
// Параметры: [message, address] - MetaMask принимает строку напрямую
|
||||||
|
const signature = await window.ethereum.request({
|
||||||
|
method: 'personal_sign',
|
||||||
|
params: [messageToSign, normalizedAddress.toLowerCase()],
|
||||||
|
});
|
||||||
|
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
return {
|
return {
|
||||||
@@ -104,7 +141,7 @@ export const connectWallet = async () => {
|
|||||||
address: normalizedAddress,
|
address: normalizedAddress,
|
||||||
signature,
|
signature,
|
||||||
nonce,
|
nonce,
|
||||||
issuedAt: new Date().toISOString(),
|
issuedAt: issuedAt, // Используем тот же issuedAt, что и в сообщении
|
||||||
};
|
};
|
||||||
// console.log('Request data:', requestData);
|
// console.log('Request data:', requestData);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
v-model:attachments="attachments"
|
v-model:attachments="attachments"
|
||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
@load-more="loadMessages"
|
@load-more="loadMessages"
|
||||||
|
@remove-consent-messages="handleRemoveConsentMessages"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
v-model:attachments="attachments"
|
v-model:attachments="attachments"
|
||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
@load-more="loadMessages"
|
@load-more="loadMessages"
|
||||||
|
@remove-consent-messages="handleRemoveConsentMessages"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +157,13 @@
|
|||||||
loadMessages({ initial: true });
|
loadMessages({ initial: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Функция удаления системных сообщений о согласиях после подписания
|
||||||
|
const handleRemoveConsentMessages = (messageIds) => {
|
||||||
|
if (messageIds && Array.isArray(messageIds)) {
|
||||||
|
messages.value = messages.value.filter(msg => !messageIds.includes(msg.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user