From e7a63dfff05137ed1dc852c154616b9e12358873 Mon Sep 17 00:00:00 2001 From: iqubik Date: Sat, 28 Mar 2026 23:46:42 +0300 Subject: [PATCH] manual auto-rotation --- client/src/api.ts | 2 +- client/src/pages/SettingsPage.tsx | 160 +++++++++++++++++- client/src/pages/SubscriptionsPage.tsx | 62 ++++++- server/package-lock.json | 57 +++++-- server/package.json | 1 + server/src/auth/jwt.strategy.ts | 2 +- server/src/rotation/rotation.controller.ts | 7 +- server/src/rotation/rotation.service.ts | 42 ++++- .../dto/create-subscription.dto.ts | 5 + .../dto/update-subscription.dto.ts | 4 + .../entities/subscription.entity.ts | 3 + .../subscriptions/subscriptions.controller.ts | 65 ++++++- .../subscriptions/subscriptions.service.ts | 17 +- 13 files changed, 391 insertions(+), 36 deletions(-) create mode 100644 server/src/subscriptions/dto/update-subscription.dto.ts diff --git a/client/src/api.ts b/client/src/api.ts index 4487e56..68cf3fb 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import { Logger } from './utils/logger'; const api = axios.create({ - baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`, + baseURL: '/api', }); // Interceptor для добавления токена к каждому запросу diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 36a19c8..4be44d3 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; +import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery, Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, FormControlLabel, Checkbox } from '@mui/material'; import api from '../api'; -import { CheckCircle, PauseCircleFilled, PlayCircleFilled } from '@mui/icons-material'; +import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Refresh } from '@mui/icons-material'; import { Logger } from '../utils/logger'; const ROTATION_PRESETS = [ @@ -10,6 +10,13 @@ const ROTATION_PRESETS = [ { label: 'Неделя', value: 10080 }, ]; +interface Subscription { + id: string; + name: string; + uuid: string; + isAutoRotationEnabled?: boolean; +} + export default function SettingsPage() { const [settings, setSettings] = useState({ xui_url: '', @@ -25,6 +32,8 @@ export default function SettingsPage() { password: '', }); + const [subs, setSubs] = useState([]); + const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' }); const [loadingRotate, setLoadingRotate] = useState(false); const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} }); @@ -51,9 +60,21 @@ export default function SettingsPage() { } }, []); + const loadSubscriptions = useCallback(async () => { + try { + Logger.debug('Loading subscriptions...', 'Settings'); + const { data } = await api.get('/subscriptions'); + setSubs(data); + Logger.debug(`Loaded ${data.length} subscriptions`, 'Settings'); + } catch (error) { + Logger.error('Failed to load subscriptions', 'Settings', error); + } + }, []); + useEffect(() => { loadSettings(); - }, [loadSettings]); + loadSubscriptions(); + }, [loadSettings, loadSubscriptions]); const getIntervalError = () => { const val = parseInt(settings.rotation_interval, 10); @@ -221,6 +242,63 @@ export default function SettingsPage() { }); }; + const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => { + try { + await api.put('/subscriptions/bulk-auto-rotation', { + subscriptionIds: [subscriptionId], + enabled + }); + setSubs(prev => prev.map(s => + s.id === subscriptionId ? { ...s, isAutoRotationEnabled: enabled } : s + )); + setMsg({ + open: true, + type: 'success', + text: enabled ? 'Авторотация включена' : 'Авторотация выключена' + }); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; + Logger.error(`Toggle auto-rotation error: ${message}`, 'Settings'); + setMsg({ open: true, type: 'error', text: message }); + loadSubscriptions(); + } + }; + + const handleManualRotate = async (sub: Subscription) => { + setConfirmDialog({ + open: true, + title: `Обновить подписку "${sub.name}" сейчас?`, + onConfirm: async () => { + try { + Logger.debug(`Starting manual rotation for subscription: ${sub.id}`, 'Settings'); + const res = await api.post(`/rotation/rotate-one/${sub.id}`); + Logger.debug('Manual rotation completed', 'Settings'); + setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация выполнена' }); + loadSubscriptions(); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации'; + Logger.error(`Manual rotation error: ${message}`, 'Settings'); + setMsg({ open: true, type: 'error', text: message }); + } + } + }); + }; + + const handleBulkUpdate = async (enabled: boolean) => { + try { + const { data } = await api.put('/subscriptions/bulk-auto-rotation', { + subscriptionIds: subs.map(s => s.id), + enabled + }); + setMsg({ open: true, type: 'success', text: data.message || 'Настройки обновлены' }); + loadSubscriptions(); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; + Logger.error(`Bulk update error: ${message}`, 'Settings'); + setMsg({ open: true, type: 'error', text: message }); + } + }; + const togglePause = async () => { const newStatus = settings.rotation_status === 'active' ? 'stopped' : 'active'; const updatedSettings = { ...settings, rotation_status: newStatus }; @@ -398,6 +476,82 @@ export default function SettingsPage() { > Сгенерировать сейчас + + + + + Управление авторотацией подписок + + + Выберите подписки для автоматической ротации: + + + {subs.length === 0 ? ( + + Нет активных подписок + + ) : ( + + {subs.map(sub => ( + + handleToggleAutoRotation(sub.id, e.target.checked)} + color="primary" + /> + } + label={ + + {sub.name} + + {sub.uuid.substring(0, 8)}... + + + } + sx={{ flexGrow: 1 }} + /> + + handleManualRotate(sub)} + color="primary" + > + + + + + ))} + + )} + + {subs.length > 0 && ( + + + + + )} diff --git a/client/src/pages/SubscriptionsPage.tsx b/client/src/pages/SubscriptionsPage.tsx index d7bf56f..c877dae 100644 --- a/client/src/pages/SubscriptionsPage.tsx +++ b/client/src/pages/SubscriptionsPage.tsx @@ -8,9 +8,10 @@ import { useMediaQuery, Menu, ListItemIcon, - ListItemText + ListItemText, + Checkbox } from '@mui/material'; -import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove } from '@mui/icons-material'; +import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove, Refresh } from '@mui/icons-material'; import api from '../api'; import { Logger } from '../utils/logger'; @@ -20,6 +21,7 @@ interface Subscription { uuid: string; inbounds: unknown[]; inboundsConfig?: unknown[]; + isAutoRotationEnabled?: boolean; } interface Tunnel { @@ -279,6 +281,48 @@ export default function SubscriptionsPage() { }); }; + const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => { + try { + await api.put('/subscriptions/bulk-auto-rotation', { + subscriptionIds: [subscriptionId], + enabled + }); + setSubs(prev => prev.map(s => + s.id === subscriptionId ? { ...s, isAutoRotationEnabled: enabled } : s + )); + setSnackbar({ + open: true, + type: 'success', + message: enabled ? 'Авторотация включена' : 'Авторотация выключена' + }); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; + Logger.error(`Toggle auto-rotation error: ${message}`, 'Subs'); + setSnackbar({ open: true, type: 'error', message }); + loadSubs(); + } + }; + + const handleManualRotate = async (sub: Subscription) => { + setConfirmDialog({ + open: true, + title: `Обновить подписку "${sub.name}" сейчас?`, + onConfirm: async () => { + try { + Logger.debug(`Starting manual rotation for subscription: ${sub.id}`, 'Subs'); + const res = await api.post(`/rotation/rotate-one/${sub.id}`); + Logger.debug('Manual rotation completed', 'Subs'); + setSnackbar({ open: true, type: 'success', message: res.data.message || 'Ротация выполнена' }); + loadSubs(); + } catch (error: unknown) { + const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации'; + Logger.error(`Manual rotation error: ${message}`, 'Subs'); + setSnackbar({ open: true, type: 'error', message }); + } + } + }); + }; + const showLinks = (sub: Subscription) => { let links: string[] = []; if (selectedServer === 'main') { @@ -334,6 +378,7 @@ export default function SubscriptionsPage() { Имя UUID Инбаунды + Авторотация Действия @@ -343,6 +388,13 @@ export default function SubscriptionsPage() { {sub.name} {sub.uuid} {sub.inbounds?.length || 0} + + handleToggleAutoRotation(sub.id, e.target.checked)} + color="primary" + /> + {!isMobile && ( <> @@ -401,6 +453,12 @@ export default function SubscriptionsPage() { Показать конфиги )} + {activeSub && ( + handleManualRotate(activeSub)}> + + Обновить сейчас + + )} {activeSub && ( handleOpenEdit(activeSub)}> diff --git a/server/package-lock.json b/server/package-lock.json index 0df26fc..dad1c94 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.1.0", @@ -759,7 +760,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -772,7 +773,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -2047,7 +2048,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2068,7 +2069,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2268,6 +2269,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", + "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", @@ -2573,28 +2594,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -3724,7 +3745,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3760,7 +3781,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -3943,7 +3964,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -4872,7 +4893,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cron": { @@ -5038,7 +5059,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -7755,7 +7776,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -10140,7 +10161,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -10524,7 +10545,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10729,7 +10750,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -11157,7 +11178,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/server/package.json b/server/package.json index 2f09617..c9e0285 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.1.0", diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index 4d59c1a..6c81a5c 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -15,7 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return token; }, ignoreExpiration: false, - secretOrKey: 'SECRET_KEY_CHANGE_ME', + secretOrKey: secret, }); const maskedSecret = secret.length > 8 diff --git a/server/src/rotation/rotation.controller.ts b/server/src/rotation/rotation.controller.ts index ed291a5..ad24fd1 100644 --- a/server/src/rotation/rotation.controller.ts +++ b/server/src/rotation/rotation.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post } from '@nestjs/common'; +import { Controller, Post, Param } from '@nestjs/common'; import { RotationService } from './rotation.service'; @Controller('rotation') @@ -9,4 +9,9 @@ export class RotationController { async rotateAll() { return this.rotationService.performRotation(); } + + @Post('rotate-one/:id') + async rotateSingle(@Param('id') id: string) { + return this.rotationService.rotateSingleSubscription(id); + } } diff --git a/server/src/rotation/rotation.service.ts b/server/src/rotation/rotation.service.ts index 6f36324..b24491d 100644 --- a/server/src/rotation/rotation.service.ts +++ b/server/src/rotation/rotation.service.ts @@ -134,7 +134,10 @@ export class RotationService implements OnModuleInit { } const subscriptions = await this.subRepo.find({ - where: { isEnabled: true }, + where: { + isEnabled: true, + isAutoRotationEnabled: true, + }, relations: ['inbounds'], }); if (subscriptions.length === 0) { @@ -344,4 +347,41 @@ export class RotationService implements OnModuleInit { if (!exists) return p; } } + + /** + * Ручная ротация одной подписки (независимо от флага isAutoRotationEnabled) + */ + async rotateSingleSubscription(subscriptionId: string) { + this.logger.debug(`Запуск ручной ротации подписки: ${subscriptionId}`); + + const sub = await this.subRepo.findOne({ + where: { id: subscriptionId }, + relations: ['inbounds'], + }); + + if (!sub) { + this.logger.warn(`Подписка не найдена: ${subscriptionId}`); + return { + success: false, + message: 'Подписка не найдена', + }; + } + + const isLoginSuccess = await this.xuiService.login(); + if (!isLoginSuccess) { + this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui'); + return { success: false, message: 'Не удалось войти в панель 3x-ui' }; + } + + const domains = await this.domainRepo.find({ where: { isEnabled: true } }); + if (domains.length === 0) { + this.logger.warn('Список доменов пуст! Ротация невозможна.'); + return { success: false, message: 'Список доменов пуст!' }; + } + + await this.rotateSubscription(sub, domains); + + this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`); + return { success: true, message: 'Ротация успешно выполнена' }; + } } diff --git a/server/src/subscriptions/dto/create-subscription.dto.ts b/server/src/subscriptions/dto/create-subscription.dto.ts index be7994e..d8f81c3 100644 --- a/server/src/subscriptions/dto/create-subscription.dto.ts +++ b/server/src/subscriptions/dto/create-subscription.dto.ts @@ -3,6 +3,7 @@ import { IsArray, ValidateNested, IsOptional, + IsBoolean, ArrayMinSize, ArrayMaxSize, } from 'class-validator'; @@ -35,4 +36,8 @@ export class CreateSubscriptionDto { @ArrayMaxSize(20) @IsOptional() inboundsConfig?: InboundConfigDto[]; + + @IsBoolean() + @IsOptional() + isAutoRotationEnabled?: boolean; } diff --git a/server/src/subscriptions/dto/update-subscription.dto.ts b/server/src/subscriptions/dto/update-subscription.dto.ts new file mode 100644 index 0000000..afa2989 --- /dev/null +++ b/server/src/subscriptions/dto/update-subscription.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSubscriptionDto } from './create-subscription.dto'; + +export class UpdateSubscriptionDto extends PartialType(CreateSubscriptionDto) {} diff --git a/server/src/subscriptions/entities/subscription.entity.ts b/server/src/subscriptions/entities/subscription.entity.ts index 6585c09..846d6d5 100644 --- a/server/src/subscriptions/entities/subscription.entity.ts +++ b/server/src/subscriptions/entities/subscription.entity.ts @@ -22,6 +22,9 @@ export class Subscription { @Column({ default: true }) isEnabled: boolean; + @Column({ default: true }) + isAutoRotationEnabled: boolean; + @Column({ type: 'simple-json', nullable: true }) inboundsConfig: Array<{ type?: string; diff --git a/server/src/subscriptions/subscriptions.controller.ts b/server/src/subscriptions/subscriptions.controller.ts index 5eab595..f81cf38 100644 --- a/server/src/subscriptions/subscriptions.controller.ts +++ b/server/src/subscriptions/subscriptions.controller.ts @@ -6,9 +6,13 @@ import { Body, Param, Put, + HttpCode, + HttpStatus, + NotFoundException, } from '@nestjs/common'; import { SubscriptionsService } from './subscriptions.service'; import { CreateSubscriptionDto } from './dto/create-subscription.dto'; +import { UpdateSubscriptionDto } from './dto/update-subscription.dto'; @Controller('subscriptions') export class SubscriptionsController { @@ -24,12 +28,63 @@ export class SubscriptionsController { return this.subscriptionsService.create(createSubscriptionDto); } - @Put(':id') - update( - @Param('id') id: string, - @Body() updateSubscriptionDto: CreateSubscriptionDto, + @Put('bulk-auto-rotation') + @HttpCode(HttpStatus.OK) + async bulkUpdateAutoRotation( + @Body() body: { subscriptionIds: string[]; enabled: boolean }, ) { - return this.subscriptionsService.update(id, updateSubscriptionDto); + const { subscriptionIds, enabled } = body; + + if (!Array.isArray(subscriptionIds)) { + return { + success: false, + message: 'subscriptionIds должен быть массивом', + }; + } + + if (subscriptionIds.length > 100) { + return { + success: false, + message: 'Максимум 100 ID за раз', + }; + } + + const updated: string[] = []; + const notFound: string[] = []; + + for (const id of subscriptionIds) { + const result = await this.subscriptionsService.update(id, { + isAutoRotationEnabled: enabled, + }); + + if (result) { + updated.push(id); + } else { + notFound.push(id); + } + } + + return { + success: true, + message: `Обновлено ${updated.length} подписок`, + updatedCount: updated.length, + notFound: notFound.length > 0 ? notFound : undefined, + }; + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() updateSubscriptionDto: UpdateSubscriptionDto, + ) { + const result = await this.subscriptionsService.update( + id, + updateSubscriptionDto, + ); + if (!result) { + throw new NotFoundException(`Подписка ${id} не найдена`); + } + return result; } @Delete(':id') diff --git a/server/src/subscriptions/subscriptions.service.ts b/server/src/subscriptions/subscriptions.service.ts index f7df780..a95b39e 100644 --- a/server/src/subscriptions/subscriptions.service.ts +++ b/server/src/subscriptions/subscriptions.service.ts @@ -1,9 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Subscription } from './entities/subscription.entity'; import { XuiService } from '../xui/xui.service'; import { CreateSubscriptionDto } from './dto/create-subscription.dto'; +import { UpdateSubscriptionDto } from './dto/update-subscription.dto'; import { v4 as uuidv4 } from 'uuid'; @Injectable() @@ -26,27 +27,35 @@ export class SubscriptionsService { name: dto.name, uuid: uuidv4(), inboundsConfig: dto.inboundsConfig || [], + isAutoRotationEnabled: dto.isAutoRotationEnabled ?? true, }); return this.subRepo.save(sub); } - async update(id: string, dto: CreateSubscriptionDto) { + async update(id: string, dto: UpdateSubscriptionDto) { const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'], }); if (!sub) { - throw new NotFoundException(`Subscription with ID ${id} not found`); + return null; } - sub.name = dto.name; + // Пустое имя не обновляется — защита от случайной очистки + if (dto.name && dto.name.trim().length > 0) { + sub.name = dto.name; + } if (dto.inboundsConfig) { sub.inboundsConfig = dto.inboundsConfig; } + if (dto.isAutoRotationEnabled !== undefined) { + sub.isAutoRotationEnabled = dto.isAutoRotationEnabled; + } + return this.subRepo.save(sub); }