fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,498 @@
import express from 'express';
import { z } from 'zod';
import crypto from 'crypto';
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import {
createBotSchema, updateBotSchema,
botLoginSchema, mcpServerSchema,
} from "@shared/schema";
import { generateTokens } from "../utils/jwt";
import { verifyPassword } from "../utils/password";
import { authLimiter } from "./shared";
import { encrypt, decrypt } from "../crypto";
const CYRILLIC_TO_LATIN: Record<string, string> = {
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',
о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ъ:'',ы:'y',
ь:'',э:'e',ю:'yu',я:'ya',
};
function transliterate(str: string): string {
return str
.toLowerCase()
.split('')
.map(c => CYRILLIC_TO_LATIN[c] ?? c)
.join('')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 50);
}
async function generateBotLogin(name: string, organizationId: number): Promise<string> {
const base = transliterate(name) || 'bot';
let login = base;
let counter = 1;
while (await storage.getBotByLogin(login, organizationId)) {
const suffix = `_${counter}`;
login = base.slice(0, 50 - suffix.length) + suffix;
counter++;
if (counter > 9999) {
login = `${base}_${Date.now()}`;
break;
}
}
return login;
}
export function registerBotCrudRoutes(app: import("express").Express): void {
// Bot authentication
app.post('/api/bot/auth/login',
authLimiter,
validateRequest(botLoginSchema),
async (req, res) => {
try {
const { login, password, organizationSlug } = req.body;
const organization = await storage.getOrganizationBySlug(organizationSlug);
if (!organization) {
return res.status(401).json({ success: false, error: 'Организация не найдена' });
}
const bot = await storage.getBotByLogin(login, organization.id);
if (!bot) {
return res.status(401).json({ success: false, error: 'Неверный логин или пароль' });
}
if (!bot.isActive) {
return res.status(401).json({ success: false, error: 'Бот деактивирован' });
}
const isValidPassword = await verifyPassword(password, bot.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ success: false, error: 'Неверный логин или пароль' });
}
const tokens = generateTokens({
userId: bot.id,
organizationId: organization.id,
appRole: 'bot'
});
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await storage.createBotSession({
botId: bot.id,
refreshToken: tokens.refreshToken,
expiresAt,
ipAddress: req.ip || null,
userAgent: req.headers['user-agent'] || null
});
res.json({
success: true,
bot: { id: bot.id, name: bot.name, login: bot.login },
organization: { id: organization.id, name: organization.name, slug: organization.slug },
tokens
});
} catch (error) {
console.error('Bot login error:', error);
res.status(500).json({ success: false, error: 'Ошибка авторизации бота' });
}
}
);
// Get all bots for organization (admin only)
app.get('/api/bots',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const bots = await storage.getBotsByOrganization(req.organizationId!);
const safeBots = bots.map(bot => ({
id: bot.id,
name: bot.name,
description: bot.description,
avatarUrl: bot.avatarUrl,
login: bot.login,
webhookUrl: bot.webhookUrl,
webhookEnabled: bot.webhookEnabled,
isActive: bot.isActive,
createdAt: bot.createdAt,
type: bot.type ?? 'webhook',
ragEnabled: bot.ragEnabled ?? false,
mcpServers: bot.mcpServers
? bot.mcpServers.map(s => ({ url: s.url, name: s.name }))
: null,
}));
res.json({ success: true, bots: safeBots });
} catch (error) {
console.error('Get bots error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении ботов' });
}
}
);
// Get bot by id (admin only)
app.get('/api/bots/:id',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const botId = parseInt(req.params.id);
if (isNaN(botId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID бота' });
}
const bot = await storage.getBotWithSubscriptions(botId, req.organizationId!);
if (!bot) {
return res.status(404).json({ success: false, error: 'Бот не найден' });
}
res.json({
success: true,
bot: {
id: bot.id,
name: bot.name,
description: bot.description,
avatarUrl: bot.avatarUrl,
login: bot.login,
webhookUrl: bot.webhookUrl,
webhookSecret: bot.webhookSecret ? '***' : null,
webhookEnabled: bot.webhookEnabled,
isActive: bot.isActive,
createdAt: bot.createdAt,
subscriptions: bot.subscriptions,
type: bot.type ?? 'webhook',
systemPrompt: bot.systemPrompt ?? null,
ragEnabled: bot.ragEnabled ?? false,
ragTopK: bot.ragTopK ?? 5,
mcpServers: bot.mcpServers
? bot.mcpServers.map(s => ({ url: s.url, name: s.name, apiKey: s.apiKey ? '***' : undefined }))
: null,
accessPolicy: bot.accessPolicy ?? null,
llmProviderId: bot.llmProviderId ?? null,
llmModel: bot.llmModel ?? null,
sendSystemPrompt: bot.sendSystemPrompt ?? true,
sendCardInfo: bot.sendCardInfo ?? true,
sendChatHistory: bot.sendChatHistory ?? true,
}
});
} catch (error) {
console.error('Get bot error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении бота' });
}
}
);
// Create bot (admin only)
app.post('/api/bots',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
validateRequest(createBotSchema),
async (req: AuthenticatedRequest, res) => {
try {
const { name, description, avatarUrl, webhookUrl, webhookSecret, type, systemPrompt, ragEnabled, ragTopK, mcpServers, accessPolicy, llmProviderId, llmModel } = req.body;
const isAiBot = type === 'ai_assistant';
const login: string = req.body.login || await generateBotLogin(name, req.organizationId!);
const password: string = req.body.password || crypto.randomBytes(16).toString('hex');
const existingBot = await storage.getBotByLogin(login, req.organizationId!);
if (existingBot) {
return res.status(400).json({ success: false, error: 'Бот с таким логином уже существует' });
}
if (llmProviderId) {
const numProv = Number(llmProviderId);
if (!Number.isInteger(numProv) || numProv <= 0) {
return res.status(400).json({ success: false, error: "Некорректный llmProviderId" });
}
const prov = await storage.getLlmProvider(numProv, req.organizationId!);
if (!prov || !prov.isActive) {
return res.status(400).json({ success: false, error: "Провайдер LLM не найден или неактивен" });
}
if (llmModel && prov.enabledModels && prov.enabledModels.length > 0 && !prov.enabledModels.includes(llmModel)) {
return res.status(400).json({ success: false, error: `Модель "${llmModel}" не входит в список разрешённых моделей провайдера` });
}
}
const bcrypt = await import('bcrypt');
const passwordHash = await bcrypt.hash(password, 12);
const finalWebhookSecret = webhookSecret || crypto.randomBytes(32).toString('hex');
const encryptedWebhookSecret = encrypt(finalWebhookSecret);
const encryptedMcpServers = mcpServers != null
? mcpServers.map((s: { url: string; apiKey?: string; name?: string }) => ({
...s,
...(s.apiKey ? { apiKey: encrypt(s.apiKey) } : {}),
}))
: undefined;
const bot = await storage.createBot({
organizationId: req.organizationId!,
name,
description: description || null,
avatarUrl: avatarUrl || null,
login,
passwordHash,
webhookUrl: webhookUrl || null,
webhookSecret: encryptedWebhookSecret,
webhookEnabled: true,
isActive: true,
createdBy: req.user!.id,
type: type ?? 'webhook',
...(systemPrompt != null && { systemPrompt }),
...(ragEnabled !== undefined && { ragEnabled }),
...(ragTopK !== undefined && { ragTopK }),
...(encryptedMcpServers != null && { mcpServers: encryptedMcpServers }),
...(accessPolicy != null && { accessPolicy }),
...(llmProviderId != null && { llmProviderId: Number(llmProviderId) }),
llmModel: llmProviderId != null ? (llmModel || null) : null,
});
res.status(201).json({
success: true,
message: 'Бот создан',
bot: {
id: bot.id,
name: bot.name,
login: bot.login,
password,
webhookSecret: finalWebhookSecret,
type: bot.type ?? 'webhook',
createdAt: bot.createdAt
}
});
} catch (error) {
console.error('Create bot error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании бота' });
}
}
);
// Update bot (admin only)
app.put('/api/bots/:id',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
validateRequest(updateBotSchema),
async (req: AuthenticatedRequest, res) => {
try {
const botId = parseInt(req.params.id);
if (isNaN(botId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID бота' });
}
const existingBot = await storage.getBot(botId, req.organizationId!);
if (!existingBot) {
return res.status(404).json({ success: false, error: 'Бот не найден' });
}
const updates: Record<string, unknown> = {};
if (req.body.name !== undefined) updates.name = req.body.name;
if (req.body.description !== undefined) updates.description = req.body.description;
if (req.body.avatarUrl !== undefined) updates.avatarUrl = req.body.avatarUrl;
if (req.body.webhookUrl !== undefined) updates.webhookUrl = req.body.webhookUrl;
if (req.body.webhookSecret !== undefined) updates.webhookSecret = encrypt(req.body.webhookSecret);
if (req.body.webhookEnabled !== undefined) updates.webhookEnabled = req.body.webhookEnabled;
if (req.body.isActive !== undefined) updates.isActive = req.body.isActive;
if (req.body.type !== undefined) updates.type = req.body.type;
if (req.body.systemPrompt !== undefined) updates.systemPrompt = req.body.systemPrompt;
if (req.body.ragEnabled !== undefined) updates.ragEnabled = req.body.ragEnabled;
if (req.body.ragTopK !== undefined) updates.ragTopK = req.body.ragTopK;
if (req.body.mcpServers !== undefined) {
const incoming: Array<{ url: string; apiKey?: string; name?: string }> = req.body.mcpServers ?? [];
const existing: Array<{ url: string; apiKey?: string; name?: string }> = existingBot.mcpServers ?? [];
updates.mcpServers = incoming.map(srv => {
if (!srv.apiKey || srv.apiKey === '***') {
const prev = existing.find(e => e.url === srv.url);
return { url: srv.url, name: srv.name, ...(prev?.apiKey ? { apiKey: prev.apiKey } : {}) };
}
return { url: srv.url, name: srv.name, apiKey: encrypt(srv.apiKey) };
});
}
if (req.body.accessPolicy !== undefined) updates.accessPolicy = req.body.accessPolicy;
if (req.body.llmProviderId !== undefined) {
if (req.body.llmProviderId) {
const n = Number(req.body.llmProviderId);
if (!Number.isInteger(n) || n <= 0) return res.status(400).json({ success: false, error: "Некорректный llmProviderId" });
updates.llmProviderId = n;
} else {
updates.llmProviderId = null;
}
}
if (req.body.sendSystemPrompt !== undefined) updates.sendSystemPrompt = req.body.sendSystemPrompt;
if (req.body.sendCardInfo !== undefined) updates.sendCardInfo = req.body.sendCardInfo;
if (req.body.sendChatHistory !== undefined) updates.sendChatHistory = req.body.sendChatHistory;
if (req.body.llmModel !== undefined) updates.llmModel = req.body.llmModel || null;
if (updates.llmProviderId === null) updates.llmModel = null;
const effectiveProviderId = updates.llmProviderId !== undefined ? (updates.llmProviderId as number | null) : existingBot.llmProviderId;
if (!effectiveProviderId && updates.llmModel !== null) updates.llmModel = null;
const effectiveModel = updates.llmModel !== undefined ? (updates.llmModel as string | null) : existingBot.llmModel;
if (effectiveProviderId) {
const prov = await storage.getLlmProvider(effectiveProviderId, req.organizationId!);
if (!prov || !prov.isActive) {
return res.status(400).json({ success: false, error: "Провайдер LLM не найден или неактивен" });
}
if (effectiveModel && prov.enabledModels && prov.enabledModels.length > 0 && !prov.enabledModels.includes(effectiveModel)) {
return res.status(400).json({ success: false, error: `Модель "${effectiveModel}" не входит в список разрешённых моделей провайдера` });
}
}
const updatedBot = await storage.updateBot(botId, req.organizationId!, updates as Parameters<typeof storage.updateBot>[2]);
res.json({
success: true,
message: 'Бот обновлен',
bot: {
id: updatedBot.id,
name: updatedBot.name,
description: updatedBot.description,
avatarUrl: updatedBot.avatarUrl,
login: updatedBot.login,
webhookUrl: updatedBot.webhookUrl,
webhookEnabled: updatedBot.webhookEnabled,
isActive: updatedBot.isActive
}
});
} catch (error) {
console.error('Update bot error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении бота' });
}
}
);
// Regenerate bot password (admin only)
app.post('/api/bots/:id/regenerate-password',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const botId = parseInt(req.params.id);
if (isNaN(botId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID бота' });
}
const bot = await storage.getBot(botId, req.organizationId!);
if (!bot) {
return res.status(404).json({ success: false, error: 'Бот не найден' });
}
const newPassword = crypto.randomBytes(16).toString('base64');
const bcrypt = await import('bcrypt');
const passwordHash = await bcrypt.hash(newPassword, 12);
await storage.updateBot(botId, req.organizationId!, { passwordHash });
await storage.deleteBotSessions(botId);
res.json({ success: true, message: 'Пароль бота обновлен', newPassword });
} catch (error) {
console.error('Regenerate bot password error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении пароля бота' });
}
}
);
// Regenerate bot webhook secret (admin only)
app.post('/api/bots/:id/regenerate-webhook-secret',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const botId = parseInt(req.params.id);
if (isNaN(botId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID бота' });
}
const bot = await storage.getBot(botId, req.organizationId!);
if (!bot) {
return res.status(404).json({ success: false, error: 'Бот не найден' });
}
const newWebhookSecret = crypto.randomBytes(32).toString('hex');
await storage.updateBot(botId, req.organizationId!, { webhookSecret: encrypt(newWebhookSecret) });
res.json({ success: true, message: 'Webhook secret обновлен', webhookSecret: newWebhookSecret });
} catch (error) {
console.error('Regenerate webhook secret error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении webhook secret' });
}
}
);
// Test MCP servers connectivity for a bot (admin only)
app.post('/api/bots/:id/mcp/test',
authenticateToken,
requirePermission('bots.manage'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const botId = parseInt(req.params.id);
if (isNaN(botId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID бота' });
}
const bot = await storage.getBot(botId, req.organizationId!);
if (!bot) {
return res.status(404).json({ success: false, error: 'Бот не найден' });
}
let mcpServers: Array<{ url: string; apiKey?: string; name?: string }> | null = null;
if (req.body && Array.isArray(req.body.servers) && req.body.servers.length > 0) {
const parsed = z.array(mcpServerSchema).safeParse(req.body.servers);
if (!parsed.success) {
return res.status(400).json({ success: false, error: 'Некорректные данные серверов MCP', details: parsed.error.flatten() });
}
const existing: Array<{ url: string; apiKey?: string; name?: string }> = bot.mcpServers ?? [];
mcpServers = parsed.data.map(srv => {
if (!srv.apiKey || srv.apiKey === '***') {
const prev = existing.find(e => e.url === srv.url);
const decryptedApiKey = prev?.apiKey ? decrypt(prev.apiKey) : undefined;
return { url: srv.url, name: srv.name, ...(decryptedApiKey ? { apiKey: decryptedApiKey } : {}) };
}
return srv;
});
} else {
mcpServers = (bot.mcpServers ?? []).map(srv => ({
...srv,
apiKey: srv.apiKey ? decrypt(srv.apiKey) : undefined,
}));
}
if (!mcpServers || mcpServers.length === 0) {
return res.json({ success: true, results: [] });
}
const { McpClient } = await import('../utils/mcp-client');
const results = await Promise.all(
mcpServers.map(async (srv) => {
const client = new McpClient(srv.url, srv.apiKey, 8000);
const ping = await client.ping();
let toolCount = 0;
if (ping.ok) {
try {
const tools = await client.listTools();
toolCount = tools.length;
} catch {}
}
return { url: srv.url, name: srv.name, ok: ping.ok, error: ping.error, toolCount };
})
);
res.json({ success: true, results });
} catch (error) {
console.error('MCP test error:', error);
res.status(500).json({ success: false, error: 'Ошибка при проверке MCP-серверов' });
}
}
);
}