feat(stations): корректный health-check + эндпоинт offline-ids

health-check переписан: живой = пришли заголовки 200-399 (рвём соединение сразу,
не ждём бесконечное тело аудиопотока), параллельно, прогон при старте + ежечасно.
Раньше GET висел на живых потоках до таймаута → ложный offline. Новый GET /stations/offline-ids
отдаёт station_id оффлайн-станций — клиент их скрывает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 17:56:59 +03:00
parent c2f638e1a1
commit 40a9f3968f
3 changed files with 101 additions and 54 deletions

View File

@@ -1,72 +1,104 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import * as http from 'http';
import * as https from 'https';
@Injectable() @Injectable()
export class HealthCheckService { export class HealthCheckService implements OnModuleInit {
private readonly logger = new Logger(HealthCheckService.name); private readonly logger = new Logger(HealthCheckService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
// Один прогон вскоре после старта, чтобы isOnline был актуален после деплоя
async onModuleInit() {
setTimeout(() => {
void this.checkAllStations();
}, 15000);
}
@Cron(CronExpression.EVERY_HOUR) @Cron(CronExpression.EVERY_HOUR)
async checkAllStations() { async checkAllStations() {
this.logger.log('Starting hourly station health check...'); this.logger.log('Проверка доступности станций...');
const stations = await this.prisma.station.findMany(); const stations = await this.prisma.station.findMany({
let onlineCount = 0; select: { id: true, streamUrl: true, isOnline: true },
let offlineCount = 0; });
for (const station of stations) { let online = 0;
try { let offline = 0;
const isOnline = await this.checkStation(station.streamUrl); const CONC = 24;
await this.prisma.station.update({
where: { id: station.id }, for (let i = 0; i < stations.length; i += CONC) {
data: { isOnline, lastCheckAt: new Date() }, const batch = stations.slice(i, i + CONC);
}); await Promise.all(
if (isOnline) onlineCount++; batch.map(async (s) => {
else offlineCount++; const isOnline = await this.isAlive(s.streamUrl);
} catch (error) { if (isOnline) online++;
this.logger.warn( else offline++;
`Failed to check station ${station.name}: ${error.message}`, // Пишем только при изменении статуса — меньше нагрузка на БД
); if (isOnline !== s.isOnline) {
await this.prisma.station.update({ await this.prisma.station
where: { id: station.id }, .update({
data: { isOnline: false, lastCheckAt: new Date() }, where: { id: s.id },
}); data: { isOnline, lastCheckAt: new Date() },
offlineCount++; })
} .catch(() => undefined);
}
}),
);
} }
this.logger.log( this.logger.log(`Проверка завершена. Online: ${online}, Offline: ${offline}`);
`Health check complete. Online: ${onlineCount}, Offline: ${offlineCount}`,
);
} }
private async checkStation(url: string): Promise<boolean> { /**
const controller = new AbortController(); * Живость потока: живой = пришли заголовки со статусом 200399.
const timeout = setTimeout(() => controller.abort(), 10000); * Аудиопоток отдаёт тело бесконечно, поэтому сразу после заголовков рвём
* соединение (req.destroy). Ошибка/4xx/5xx/таймаут = мёртв. 2 попытки.
try { */
const response = await fetch(url, { private async isAlive(url: string): Promise<boolean> {
method: 'HEAD', for (let attempt = 0; attempt < 2; attempt++) {
signal: controller.signal, if (await this.probe(url)) return true;
}); await this.sleep(300);
clearTimeout(timeout);
return response.status >= 200 && response.status < 400;
} catch {
clearTimeout(timeout);
// Fallback to GET if HEAD fails
try {
const controller2 = new AbortController();
const timeout2 = setTimeout(() => controller2.abort(), 10000);
const response = await fetch(url, {
method: 'GET',
signal: controller2.signal,
});
clearTimeout(timeout2);
return response.status >= 200 && response.status < 400;
} catch {
return false;
}
} }
return false;
}
private probe(url: string): Promise<boolean> {
return new Promise((resolve) => {
let done = false;
const finish = (v: boolean) => {
if (!done) {
done = true;
resolve(v);
}
};
try {
const lib = url.startsWith('https') ? https : http;
const req = lib.get(
url,
{
timeout: 8000,
headers: { 'User-Agent': 'Mozilla/5.0', 'Icy-MetaData': '1' },
},
(res) => {
const code = res.statusCode ?? 0;
req.destroy();
finish(code >= 200 && code < 400);
},
);
req.on('error', () => finish(false));
req.on('timeout', () => {
req.destroy();
finish(false);
});
} catch {
finish(false);
}
});
}
private sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
} }
} }

View File

@@ -36,6 +36,12 @@ export class StationsController {
}); });
} }
@Get('offline-ids')
@ApiOperation({ summary: 'station_id оффлайн-станций (для скрытия в клиенте)' })
async offlineIds() {
return this.stationsService.getOfflineStationIds();
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get station by ID' }) @ApiOperation({ summary: 'Get station by ID' })
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string) {

View File

@@ -35,6 +35,15 @@ export class StationsService {
}); });
} }
// station_id оффлайн-станций — для скрытия мёртвых плиток в клиенте
async getOfflineStationIds(): Promise<number[]> {
const rows = await this.prisma.station.findMany({
where: { isOnline: false },
select: { stationId: true },
});
return rows.map((r) => r.stationId);
}
async findOne(id: string) { async findOne(id: string) {
const station = await this.prisma.station.findUnique({ const station = await this.prisma.station.findUnique({
where: { id }, where: { id },