BIG_UPDATE deleted vps, added s3 infrastructure.

This commit is contained in:
Georgiy Syralev
2025-11-23 14:35:16 +03:00
parent ae1f93a934
commit c4c2610480
173 changed files with 22684 additions and 5894 deletions

View File

@@ -0,0 +1,65 @@
# Решение проблемы Foreign Key при удалении тарифов
## Проблема
При попытке удалить тариф через Prisma Studio появляется ошибка:
```
Foreign key constraint violated on the fields: (`tariffId`)
```
## Причина
Тариф используется серверами. MySQL не позволяет удалить тариф, если на него ссылаются записи в таблице `server`.
## Решение
### Способ 1: Безопасный (рекомендуется)
Удаляет только неиспользуемые тарифы, сохраняет серверы.
```bash
mysql -u root -p ospabhost < backend/prisma/safe_tariff_migration.sql
```
### Способ 2: Полная очистка (только для dev!)
Удаляет ВСЕ серверы и тарифы.
```bash
# Сначала бэкап!
mysqldump -u root -p ospabhost > backup.sql
# Потом очистка
mysql -u root -p ospabhost < backend/prisma/clean_slate_migration.sql
```
### Способ 3: Ручное удаление через SQL
```sql
-- 1. Найти тарифы без серверов
SELECT t.id, t.name, COUNT(s.id) as servers
FROM tariff t
LEFT JOIN server s ON s.tariffId = t.id
GROUP BY t.id
HAVING servers = 0;
-- 2. Удалить только неиспользуемые
DELETE FROM tariff WHERE id IN (
SELECT id FROM (
SELECT t.id FROM tariff t
LEFT JOIN server s ON s.tariffId = t.id
GROUP BY t.id
HAVING COUNT(s.id) = 0
) as unused
);
-- 3. Добавить категорию
ALTER TABLE tariff ADD COLUMN category VARCHAR(50) NOT NULL DEFAULT 'vps';
-- 4. Добавить новые тарифы (см. safe_tariff_migration.sql)
```
## Перезапуск backend
```bash
cd backend
npm start
```
Готово! 🎉

View File

@@ -0,0 +1,43 @@
-- Добавление поля category в таблицу tariff
ALTER TABLE `tariff` ADD COLUMN `category` VARCHAR(50) NOT NULL DEFAULT 'vps' AFTER `description`;
-- Удаление старых тарифов (если нужно)
-- DELETE FROM `tariff`;
-- ============================================
-- VPS/VDS Тарифы
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('VPS Starter', 299, '1 vCore, 1 GB RAM, 10 GB SSD, 1 TB трафик', 'vps'),
('VPS Basic', 499, '2 vCore, 2 GB RAM, 20 GB SSD, 2 TB трафик', 'vps'),
('VPS Standard', 899, '4 vCore, 4 GB RAM, 40 GB SSD, 3 TB трафик', 'vps'),
('VPS Advanced', 1499, '6 vCore, 8 GB RAM, 80 GB SSD, 5 TB трафик', 'vps'),
('VPS Pro', 2499, '8 vCore, 16 GB RAM, 160 GB SSD, Unlimited трафик', 'vps'),
('VPS Enterprise', 4999, '16 vCore, 32 GB RAM, 320 GB SSD, Unlimited трафик', 'vps');
-- ============================================
-- Хостинг для сайтов
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('Хостинг Lite', 149, '1 сайт, 5 GB SSD, 10 GB трафик, 1 БД MySQL', 'hosting'),
('Хостинг Start', 249, '3 сайта, 10 GB SSD, 50 GB трафик, 3 БД MySQL', 'hosting'),
('Хостинг Plus', 449, '10 сайтов, 25 GB SSD, 100 GB трафик, 10 БД MySQL', 'hosting'),
('Хостинг Business', 799, 'Безлимит сайтов, 50 GB SSD, 500 GB трафик, Безлимит БД', 'hosting'),
('Хостинг Premium', 1299, 'Безлимит сайтов, 100 GB SSD, Unlimited трафик, Безлимит БД, SSL', 'hosting');
-- ============================================
-- S3 Хранилище
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('S3 Mini', 99, '10 GB хранилище, 50 GB трафик, API доступ', 's3'),
('S3 Basic', 199, '50 GB хранилище, 200 GB трафик, API доступ', 's3'),
('S3 Standard', 399, '200 GB хранилище, 1 TB трафик, API доступ, CDN', 's3'),
('S3 Advanced', 799, '500 GB хранилище, 3 TB трафик, API доступ, CDN', 's3'),
('S3 Pro', 1499, '1 TB хранилище, 10 TB трафик, API доступ, CDN, Priority Support', 's3'),
('S3 Enterprise', 2999, '5 TB хранилище, Unlimited трафик, API доступ, CDN, Priority Support', 's3');
-- Проверка добавленных тарифов
SELECT * FROM `tariff` ORDER BY `category`, `price`;

View File

@@ -0,0 +1,57 @@
import { prisma } from '../src/prisma/client';
import fs from 'fs';
import path from 'path';
async function applyMigration() {
try {
const sqlPath = path.join(__dirname, 'migrations_manual', 'add_sessions_qr_tickets_features.sql');
const sql = fs.readFileSync(sqlPath, 'utf-8');
// Удаляем комментарии и разделяем по точке с запятой
const cleaned = sql
.split('\n')
.filter(line => !line.trim().startsWith('--'))
.join('\n');
const statements = cleaned
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0);
console.log(`🚀 Применяю миграцию: ${statements.length} запросов...`);
for (let i = 0; i < statements.length; i++) {
const statement = statements[i];
const preview = statement.replace(/\s+/g, ' ').substring(0, 150);
console.log(`\n[${i + 1}/${statements.length}] Выполняю:`);
console.log(preview + '...');
try {
await prisma.$executeRawUnsafe(statement);
console.log('✅ Успешно');
} catch (error: any) {
// Игнорируем ошибки "duplicate column" и "table already exists"
if (
error.message.includes('Duplicate column') ||
error.message.includes('already exists') ||
error.message.includes('Duplicate key')
) {
console.log('⚠️ Уже существует, пропускаю...');
} else {
console.error('❌ Ошибка:', error.message);
// Не выбрасываем ошибку, продолжаем выполнение
}
}
}
console.log('\n✅ Миграция завершена!');
process.exit(0);
} catch (error) {
console.error('❌ Критическая ошибка:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
applyMigration();

View File

@@ -0,0 +1,73 @@
-- ============================================
-- ВНИМАНИЕ! Этот скрипт удалит ВСЕ серверы и тарифы
-- Используйте только если хотите начать с чистого листа
-- ============================================
-- Шаг 1: Проверяем, что будет удалено
SELECT 'Servers to delete:' as info, COUNT(*) as count FROM `server`;
SELECT 'Tariffs to delete:' as info, COUNT(*) as count FROM `tariff`;
-- Шаг 2: Удаляем все связанные данные (в правильном порядке)
-- Удаляем метрики серверов
DELETE FROM `server_metrics`;
-- Удаляем платежи
DELETE FROM `payment`;
-- Удаляем серверы (это разрешит удаление тарифов)
DELETE FROM `server`;
-- Удаляем все тарифы
DELETE FROM `tariff`;
-- Шаг 3: Добавляем поле category
ALTER TABLE `tariff`
ADD COLUMN IF NOT EXISTS `category` VARCHAR(50) NOT NULL DEFAULT 'vps'
AFTER `description`;
-- Шаг 4: Добавляем новые тарифы
-- ============================================
-- VPS/VDS Тарифы
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('VPS Starter', 299, '1 vCore, 1 GB RAM, 10 GB SSD, 1 TB трафик', 'vps'),
('VPS Basic', 499, '2 vCore, 2 GB RAM, 20 GB SSD, 2 TB трафик', 'vps'),
('VPS Standard', 899, '4 vCore, 4 GB RAM, 40 GB SSD, 3 TB трафик', 'vps'),
('VPS Advanced', 1499, '6 vCore, 8 GB RAM, 80 GB SSD, 5 TB трафик', 'vps'),
('VPS Pro', 2499, '8 vCore, 16 GB RAM, 160 GB SSD, Unlimited трафик', 'vps'),
('VPS Enterprise', 4999, '16 vCore, 32 GB RAM, 320 GB SSD, Unlimited трафик', 'vps');
-- ============================================
-- Хостинг для сайтов
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('Хостинг Lite', 149, '1 сайт, 5 GB SSD, 10 GB трафик, 1 БД MySQL', 'hosting'),
('Хостинг Start', 249, '3 сайта, 10 GB SSD, 50 GB трафик, 3 БД MySQL', 'hosting'),
('Хостинг Plus', 449, '10 сайтов, 25 GB SSD, 100 GB трафик, 10 БД MySQL', 'hosting'),
('Хостинг Business', 799, 'Безлимит сайтов, 50 GB SSD, 500 GB трафик, Безлимит БД', 'hosting'),
('Хостинг Premium', 1299, 'Безлимит сайтов, 100 GB SSD, Unlimited трафик, Безлимит БД, SSL', 'hosting');
-- ============================================
-- S3 Хранилище
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('S3 Mini', 99, '10 GB хранилище, 50 GB трафик, API доступ', 's3'),
('S3 Basic', 199, '50 GB хранилище, 200 GB трафик, API доступ', 's3'),
('S3 Standard', 399, '200 GB хранилище, 1 TB трафик, API доступ, CDN', 's3'),
('S3 Advanced', 799, '500 GB хранилище, 3 TB трафик, API доступ, CDN', 's3'),
('S3 Pro', 1499, '1 TB хранилище, 10 TB трафик, API доступ, CDN, Priority Support', 's3'),
('S3 Enterprise', 2999, '5 TB хранилище, Unlimited трафик, API доступ, CDN, Priority Support', 's3');
-- Шаг 5: Проверка
SELECT 'New tariffs:' as info;
SELECT * FROM `tariff` ORDER BY `category`, `price`;
-- Сброс AUTO_INCREMENT (опционально)
ALTER TABLE `tariff` AUTO_INCREMENT = 1;
ALTER TABLE `server` AUTO_INCREMENT = 1;
ALTER TABLE `payment` AUTO_INCREMENT = 1;
ALTER TABLE `server_metrics` AUTO_INCREMENT = 1;

View File

@@ -0,0 +1,18 @@
-- ============================================
-- Миграция: Добавление category к тарифам
-- Дата: 8 ноября 2025
-- ============================================
-- Шаг 1: Добавление поля category (если ещё не существует)
ALTER TABLE `tariff`
ADD COLUMN IF NOT EXISTS `category` VARCHAR(50) NOT NULL DEFAULT 'vps'
AFTER `description`;
-- Шаг 2: Обновление существующих тарифов (если есть)
UPDATE `tariff` SET `category` = 'vps' WHERE `category` IS NULL OR `category` = '';
-- Проверка структуры таблицы
DESCRIBE `tariff`;
-- Показать текущие тарифы
SELECT * FROM `tariff` ORDER BY `category`, `price`;

View File

@@ -0,0 +1,25 @@
-- Миграция для добавления таблицы метрик серверов
CREATE TABLE `server_metrics` (
`id` INT NOT NULL AUTO_INCREMENT,
`serverId` INT NOT NULL,
`cpuUsage` DOUBLE NOT NULL DEFAULT 0,
`memoryUsage` DOUBLE NOT NULL DEFAULT 0,
`memoryUsed` BIGINT NOT NULL DEFAULT 0,
`memoryMax` BIGINT NOT NULL DEFAULT 0,
`diskUsage` DOUBLE NOT NULL DEFAULT 0,
`diskUsed` BIGINT NOT NULL DEFAULT 0,
`diskMax` BIGINT NOT NULL DEFAULT 0,
`networkIn` BIGINT NOT NULL DEFAULT 0,
`networkOut` BIGINT NOT NULL DEFAULT 0,
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
`uptime` BIGINT NOT NULL DEFAULT 0,
`timestamp` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `server_metrics_serverId_timestamp_idx` (`serverId`, `timestamp`),
CONSTRAINT `server_metrics_serverId_fkey`
FOREIGN KEY (`serverId`) REFERENCES `server`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,125 @@
-- Миграция: Добавление системы сессий, QR-авторизации и улучшенных тикетов
-- Дата: 2025-11-09
-- 1. Обновление таблицы ticket (добавление новых полей)
ALTER TABLE `ticket`
MODIFY COLUMN `message` TEXT NOT NULL,
ADD COLUMN `priority` VARCHAR(20) DEFAULT 'normal' AFTER `status`,
ADD COLUMN `category` VARCHAR(50) DEFAULT 'general' AFTER `priority`,
ADD COLUMN `assignedTo` INT NULL AFTER `category`,
ADD COLUMN `closedAt` DATETIME NULL AFTER `updatedAt`;
-- 2. Обновление таблицы response
ALTER TABLE `response`
MODIFY COLUMN `message` TEXT NOT NULL,
ADD COLUMN `isInternal` BOOLEAN DEFAULT FALSE AFTER `message`;
-- 3. Создание таблицы для прикреплённых файлов к тикетам
CREATE TABLE IF NOT EXISTS `ticket_attachment` (
`id` INT NOT NULL AUTO_INCREMENT,
`ticketId` INT NOT NULL,
`filename` VARCHAR(255) NOT NULL,
`fileUrl` VARCHAR(500) NOT NULL,
`fileSize` INT NOT NULL,
`mimeType` VARCHAR(100) NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `ticketId_idx` (`ticketId`),
CONSTRAINT `ticket_attachment_ticketId_fkey`
FOREIGN KEY (`ticketId`)
REFERENCES `ticket`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. Создание таблицы для прикреплённых файлов к ответам
CREATE TABLE IF NOT EXISTS `response_attachment` (
`id` INT NOT NULL AUTO_INCREMENT,
`responseId` INT NOT NULL,
`filename` VARCHAR(255) NOT NULL,
`fileUrl` VARCHAR(500) NOT NULL,
`fileSize` INT NOT NULL,
`mimeType` VARCHAR(100) NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `responseId_idx` (`responseId`),
CONSTRAINT `response_attachment_responseId_fkey`
FOREIGN KEY (`responseId`)
REFERENCES `response`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. Создание таблицы QR-авторизации
CREATE TABLE IF NOT EXISTS `qr_login_request` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(128) NOT NULL,
`userId` INT NULL,
`status` VARCHAR(20) DEFAULT 'pending',
`ipAddress` VARCHAR(45) NULL,
`userAgent` TEXT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expiresAt` DATETIME NOT NULL,
`confirmedAt` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `code_unique` (`code`),
INDEX `code_idx` (`code`),
INDEX `status_expiresAt_idx` (`status`, `expiresAt`),
INDEX `userId_idx` (`userId`),
CONSTRAINT `qr_login_request_userId_fkey`
FOREIGN KEY (`userId`)
REFERENCES `user`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. Проверка и создание таблицы session (если не существует)
CREATE TABLE IF NOT EXISTS `session` (
`id` INT NOT NULL AUTO_INCREMENT,
`userId` INT NOT NULL,
`token` VARCHAR(500) NOT NULL,
`ipAddress` VARCHAR(45) NULL,
`userAgent` TEXT NULL,
`device` VARCHAR(50) NULL,
`browser` VARCHAR(50) NULL,
`location` VARCHAR(200) NULL,
`lastActivity` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expiresAt` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `token_unique` (`token`),
INDEX `userId_idx` (`userId`),
CONSTRAINT `session_userId_fkey`
FOREIGN KEY (`userId`)
REFERENCES `user`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 7. Проверка и создание таблицы login_history (если не существует)
CREATE TABLE IF NOT EXISTS `login_history` (
`id` INT NOT NULL AUTO_INCREMENT,
`userId` INT NOT NULL,
`ipAddress` VARCHAR(45) NOT NULL,
`userAgent` TEXT NULL,
`device` VARCHAR(50) NULL,
`browser` VARCHAR(50) NULL,
`location` VARCHAR(200) NULL,
`success` BOOLEAN DEFAULT TRUE,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `userId_idx` (`userId`),
INDEX `createdAt_idx` (`createdAt`),
CONSTRAINT `login_history_userId_fkey`
FOREIGN KEY (`userId`)
REFERENCES `user`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 8. Обновление статусов тикетов для существующих записей
UPDATE `ticket` SET `priority` = 'normal' WHERE `priority` IS NULL;
UPDATE `ticket` SET `category` = 'general' WHERE `category` IS NULL;
-- Готово!
SELECT 'Migration completed successfully!' as status;

View File

@@ -0,0 +1,86 @@
-- ============================================
-- Безопасная миграция тарифов
-- Удаление старых тарифов с учётом foreign key
-- ============================================
-- Шаг 1: Проверяем, какие тарифы используются серверами
SELECT
t.id,
t.name,
COUNT(s.id) as servers_count
FROM `tariff` t
LEFT JOIN `server` s ON s.tariffId = t.id
GROUP BY t.id, t.name
ORDER BY t.name;
-- Шаг 2: Добавляем поле category (если ещё нет)
ALTER TABLE `tariff`
ADD COLUMN IF NOT EXISTS `category` VARCHAR(50) NOT NULL DEFAULT 'vps'
AFTER `description`;
-- Шаг 3: ВАРИАНТ А - Обновить существующие тарифы вместо удаления
-- Присваиваем категории существующим тарифам
UPDATE `tariff` SET `category` = 'vps' WHERE `category` IS NULL OR `category` = '';
-- Шаг 4: ВАРИАНТ Б - Удалить только неиспользуемые тарифы
-- Создаём временную таблицу с ID используемых тарифов
CREATE TEMPORARY TABLE used_tariffs AS
SELECT DISTINCT tariffId FROM `server`;
-- Удаляем только те тарифы, которые НЕ используются
DELETE FROM `tariff`
WHERE id NOT IN (SELECT tariffId FROM used_tariffs);
-- Удаляем временную таблицу
DROP TEMPORARY TABLE used_tariffs;
-- Шаг 5: Добавляем новые тарифы
-- ============================================
-- VPS/VDS Тарифы
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('VPS Starter', 299, '1 vCore, 1 GB RAM, 10 GB SSD, 1 TB трафик', 'vps'),
('VPS Basic', 499, '2 vCore, 2 GB RAM, 20 GB SSD, 2 TB трафик', 'vps'),
('VPS Standard', 899, '4 vCore, 4 GB RAM, 40 GB SSD, 3 TB трафик', 'vps'),
('VPS Advanced', 1499, '6 vCore, 8 GB RAM, 80 GB SSD, 5 TB трафик', 'vps'),
('VPS Pro', 2499, '8 vCore, 16 GB RAM, 160 GB SSD, Unlimited трафик', 'vps'),
('VPS Enterprise', 4999, '16 vCore, 32 GB RAM, 320 GB SSD, Unlimited трафик', 'vps');
-- ============================================
-- Хостинг для сайтов
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('Хостинг Lite', 149, '1 сайт, 5 GB SSD, 10 GB трафик, 1 БД MySQL', 'hosting'),
('Хостинг Start', 249, '3 сайта, 10 GB SSD, 50 GB трафик, 3 БД MySQL', 'hosting'),
('Хостинг Plus', 449, '10 сайтов, 25 GB SSD, 100 GB трафик, 10 БД MySQL', 'hosting'),
('Хостинг Business', 799, 'Безлимит сайтов, 50 GB SSD, 500 GB трафик, Безлимит БД', 'hosting'),
('Хостинг Premium', 1299, 'Безлимит сайтов, 100 GB SSD, Unlimited трафик, Безлимит БД, SSL', 'hosting');
-- ============================================
-- S3 Хранилище
-- ============================================
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
('S3 Mini', 99, '10 GB хранилище, 50 GB трафик, API доступ', 's3'),
('S3 Basic', 199, '50 GB хранилище, 200 GB трафик, API доступ', 's3'),
('S3 Standard', 399, '200 GB хранилище, 1 TB трафик, API доступ, CDN', 's3'),
('S3 Advanced', 799, '500 GB хранилище, 3 TB трафик, API доступ, CDN', 's3'),
('S3 Pro', 1499, '1 TB хранилище, 10 TB трафик, API доступ, CDN, Priority Support', 's3'),
('S3 Enterprise', 2999, '5 TB хранилище, Unlimited трафик, API доступ, CDN, Priority Support', 's3');
-- Проверка добавленных тарифов
SELECT * FROM `tariff` ORDER BY `category`, `price`;
-- Показать количество серверов для каждого тарифа
SELECT
t.id,
t.name,
t.category,
t.price,
COUNT(s.id) as active_servers
FROM `tariff` t
LEFT JOIN `server` s ON s.tariffId = t.id
GROUP BY t.id, t.name, t.category, t.price
ORDER BY t.category, t.price;

View File

@@ -10,71 +10,7 @@ datasource db {
}
// This is your Prisma schema file,
model Tariff {
id Int @id @default(autoincrement())
name String @unique
price Float
description String?
createdAt DateTime @default(now())
servers Server[]
@@map("tariff")
}
model OperatingSystem {
id Int @id @default(autoincrement())
name String @unique
type String // linux, windows, etc
template String? // путь к шаблону для контейнера
createdAt DateTime @default(now())
servers Server[]
@@map("operatingsystem")
}
model Server {
id Int @id @default(autoincrement())
userId Int
tariffId Int
osId Int
status String @default("creating") // creating, running, stopped, suspended, error
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
tariff Tariff @relation(fields: [tariffId], references: [id])
os OperatingSystem @relation(fields: [osId], references: [id])
// Proxmox данные
node String?
diskTemplate String?
proxmoxId Int?
// Сетевые настройки
ipAddress String? // Локальный IP адрес
macAddress String? // MAC адрес
// Доступы
rootPassword String? // Зашифрованный root пароль
sshPublicKey String? // SSH публичный ключ (опционально)
// Мониторинг
lastPing DateTime?
cpuUsage Float? @default(0)
memoryUsage Float? @default(0)
diskUsage Float? @default(0)
networkIn Float? @default(0)
networkOut Float? @default(0)
// Автоматические платежи
nextPaymentDate DateTime? // Дата следующего списания
autoRenew Boolean @default(true) // Автопродление
payments Payment[]
@@map("server")
}
// VPS/Server models removed - moving to S3 storage
model User {
id Int @id @default(autoincrement())
@@ -89,10 +25,20 @@ model User {
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
servers Server[]
notifications Notification[]
payments Payment[]
pushSubscriptions PushSubscription[]
transactions Transaction[] // История всех транзакций
posts Post[] @relation("PostAuthor") // Статьи блога
comments Comment[] @relation("UserComments") // Комментарии
buckets StorageBucket[] // S3 хранилища пользователя
// Новые relations для расширенных настроек
sessions Session[]
loginHistory LoginHistory[]
apiKeys APIKey[]
notificationSettings NotificationSettings?
profile UserProfile?
qrLoginRequests QrLoginRequest[]
@@map("user")
}
@@ -136,55 +82,86 @@ model Service {
model Ticket {
id Int @id @default(autoincrement())
title String
message String
message String @db.Text
userId Int
status String @default("open")
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
priority String @default("normal") // low, normal, high, urgent
category String @default("general") // general, technical, billing, other
assignedTo Int? // ID оператора, которому назначен тикет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
closedAt DateTime?
responses Response[] @relation("TicketResponses")
attachments TicketAttachment[]
user User? @relation("UserTickets", fields: [userId], references: [id])
@@map("ticket")
@@map("ticket")
}
model Response {
id Int @id @default(autoincrement())
ticketId Int
operatorId Int
message String
message String @db.Text
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
createdAt DateTime @default(now())
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id])
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
attachments ResponseAttachment[]
@@map("response")
}
model Notification {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
title String
message String
createdAt DateTime @default(now())
@@map("notification")
@@map("response")
}
// Автоматические платежи за серверы
model Payment {
// Прикреплённые файлы к тикетам
model TicketAttachment {
id Int @id @default(autoincrement())
userId Int
serverId Int
amount Float
status String @default("pending") // pending, success, failed
type String // subscription, manual
ticketId Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int // Размер в байтах
mimeType String
createdAt DateTime @default(now())
processedAt DateTime?
user User @relation(fields: [userId], references: [id])
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@map("ticket_attachment")
}
@@map("payment")
// Прикреплённые файлы к ответам
model ResponseAttachment {
id Int @id @default(autoincrement())
responseId Int
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int
mimeType String
createdAt DateTime @default(now())
@@map("response_attachment")
}
// QR-код авторизация (как в Telegram Web)
model QrLoginRequest {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(128) // Уникальный код QR
userId Int? // После подтверждения - ID пользователя
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
status String @default("pending") // pending, confirmed, expired, rejected
ipAddress String?
userAgent String? @db.Text
createdAt DateTime @default(now())
expiresAt DateTime // Через 60 секунд
confirmedAt DateTime?
@@index([code])
@@index([status, expiresAt])
@@map("qr_login_request")
}
// История всех транзакций (пополнения, списания, возвраты)
@@ -201,4 +178,242 @@ model Transaction {
user User @relation(fields: [userId], references: [id])
@@map("transaction")
}
// Блог
model Post {
id Int @id @default(autoincrement())
title String
content String @db.Text // Rich text content (HTML)
excerpt String? @db.Text // Краткое описание для ленты
coverImage String? // URL обложки
url String @unique // Пользовательский URL (blog_name)
status String @default("draft") // draft, published, archived
authorId Int
author User @relation("PostAuthor", fields: [authorId], references: [id])
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
comments Comment[]
@@map("post")
}
// Комментарии к статьям блога
model Comment {
id Int @id @default(autoincrement())
postId Int
userId Int? // null если комментарий от гостя
authorName String? // Имя автора (для гостей)
content String @db.Text
status String @default("pending") // pending, approved, rejected
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("UserComments", fields: [userId], references: [id])
@@map("comment")
}
// Модель для уведомлений
model Notification {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
title String
message String @db.Text
// Связанные сущности (опционально)
ticketId Int?
checkId Int?
// Метаданные
actionUrl String? // URL для перехода при клике
icon String? // Иконка (emoji или path)
color String? // Цвет (green, blue, orange, red, purple)
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, isRead])
@@index([userId, createdAt])
@@map("notification")
}
// Модель для Push-подписок (Web Push API)
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @db.VarChar(512)
p256dh String @db.Text // Публичный ключ для шифрования
auth String @db.Text // Токен аутентификации
userAgent String? @db.Text // Браузер/устройство
createdAt DateTime @default(now())
lastUsed DateTime @default(now())
@@unique([userId, endpoint])
@@index([userId])
@@map("push_subscription")
}
// Активные сеансы пользователя
model Session {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique @db.VarChar(500) // JWT refresh token
ipAddress String?
userAgent String? @db.Text
device String? // Desktop, Mobile, Tablet
browser String? // Chrome, Firefox, Safari, etc.
location String? // Город/страна по IP
lastActivity DateTime @default(now())
createdAt DateTime @default(now())
expiresAt DateTime
@@index([userId])
@@map("session")
}
// История входов
model LoginHistory {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ipAddress String
userAgent String? @db.Text
device String?
browser String?
location String?
success Boolean @default(true) // true = успешный вход, false = неудачная попытка
createdAt DateTime @default(now())
@@index([userId])
@@index([createdAt])
@@map("login_history")
}
// API ключи для разработчиков
model APIKey {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Название (например, "Production API")
key String @unique @db.VarChar(64) // Сам API ключ
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
lastUsed DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
@@index([userId])
@@index([key])
@@map("api_key")
}
// Настройки уведомлений пользователя
model NotificationSettings {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Email уведомления
emailBalanceLow Boolean @default(true)
emailPaymentCharged Boolean @default(true)
emailTicketReply Boolean @default(true)
emailNewsletter Boolean @default(false)
// Push уведомления
pushBalanceLow Boolean @default(true)
pushPaymentCharged Boolean @default(true)
pushTicketReply Boolean @default(true)
updatedAt DateTime @updatedAt
@@map("notification_settings")
}
// Настройки профиля
model UserProfile {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
avatarUrl String? // Путь к аватару
phoneNumber String?
timezone String? @default("Europe/Moscow")
language String? @default("ru")
// Настройки приватности
profilePublic Boolean @default(false)
showEmail Boolean @default(false)
// 2FA
twoFactorEnabled Boolean @default(false)
twoFactorSecret String? @db.Text
updatedAt DateTime @updatedAt
@@map("user_profile")
}
// S3 Bucket модель
model StorageBucket {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Уникальное имя бакета в рамках пользователя
plan String // Выбранный тариф (basic, standard, plus, pro, enterprise)
quotaGb Int // Лимит включённого объёма в GB
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
objectCount Int @default(0)
storageClass String @default("standard") // standard, infrequent, archive
region String @default("ru-central-1")
public Boolean @default(false)
versioning Boolean @default(false)
status String @default("active") // active, grace, suspended
monthlyPrice Float
nextBillingDate DateTime?
lastBilledAt DateTime?
autoRenew Boolean @default(true)
usageSyncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accessKeys StorageAccessKey[]
@@index([userId])
@@unique([userId, name]) // Имя уникально в рамках пользователя
@@map("storage_bucket")
}
model StorageAccessKey {
id Int @id @default(autoincrement())
bucketId Int
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
accessKey String @unique
secretKey String // хранится в зашифрованном виде
label String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([bucketId])
@@map("storage_access_key")
}