manual auto-rotation

This commit is contained in:
iqubik
2026-03-28 23:46:42 +03:00
parent 9fdbde3cab
commit e7a63dfff0
13 changed files with 391 additions and 36 deletions
+1 -1
View File
@@ -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 для добавления токена к каждому запросу
+157 -3
View File
@@ -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<Subscription[]>([]);
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [loadingRotate, setLoadingRotate] = useState<boolean>(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() {
>
Сгенерировать сейчас
</Button>
<Divider sx={{ my: 3 }} />
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600 }}>
Управление авторотацией подписок
</Typography>
<Typography variant="body2" color="textSecondary" paragraph>
Выберите подписки для автоматической ротации:
</Typography>
{subs.length === 0 ? (
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Нет активных подписок
</Typography>
) : (
<List sx={{ maxHeight: 400, overflow: 'auto', bgcolor: 'background.default', borderRadius: 1 }}>
{subs.map(sub => (
<ListItem
key={sub.id}
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': { borderBottom: 'none' }
}}
>
<FormControlLabel
control={
<Checkbox
checked={sub.isAutoRotationEnabled ?? true}
onChange={(e) => handleToggleAutoRotation(sub.id, e.target.checked)}
color="primary"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{sub.name}</Typography>
<Typography variant="caption" color="textSecondary">
{sub.uuid.substring(0, 8)}...
</Typography>
</Box>
}
sx={{ flexGrow: 1 }}
/>
<Tooltip title="Обновить подписку вручную">
<IconButton
size="small"
onClick={() => handleManualRotate(sub)}
color="primary"
>
<Refresh />
</IconButton>
</Tooltip>
</ListItem>
))}
</List>
)}
{subs.length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={() => handleBulkUpdate(true)}
>
Включить для всех
</Button>
<Button
variant="outlined"
size="small"
onClick={() => handleBulkUpdate(false)}
>
Выключить для всех
</Button>
</Box>
)}
</Paper>
<Paper sx={{ p: 3 }}>
+60 -2
View File
@@ -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() {
<TableCell>Имя</TableCell>
<TableCell>UUID</TableCell>
<TableCell>Инбаунды</TableCell>
<TableCell>Авторотация</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
@@ -343,6 +388,13 @@ export default function SubscriptionsPage() {
<TableCell sx={{ fontWeight: 700 }}>{sub.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
<TableCell>{sub.inbounds?.length || 0}</TableCell>
<TableCell>
<Checkbox
checked={sub.isAutoRotationEnabled ?? true}
onChange={(e) => handleToggleAutoRotation(sub.id, e.target.checked)}
color="primary"
/>
</TableCell>
<TableCell align="right">
{!isMobile && (
<>
@@ -401,6 +453,12 @@ export default function SubscriptionsPage() {
<ListItemText>Показать конфиги</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => handleManualRotate(activeSub)}>
<ListItemIcon><Refresh fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Обновить сейчас</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => handleOpenEdit(activeSub)}>
<ListItemIcon><Edit fontSize="small" /></ListItemIcon>
+39 -18
View File
@@ -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"
+1
View File
@@ -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",
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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);
}
}
+41 -1
View File
@@ -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: 'Ротация успешно выполнена' };
}
}
@@ -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;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSubscriptionDto } from './create-subscription.dto';
export class UpdateSubscriptionDto extends PartialType(CreateSubscriptionDto) {}
@@ -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;
@@ -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')
@@ -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;
}
// Пустое имя не обновляется — защита от случайной очистки
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);
}