first commit

This commit is contained in:
Первов Артем
2026-06-19 08:18:52 +03:00
commit 05a9e0e535
61 changed files with 11950 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs';
import { getIntegerConfig } from '../common/config-number';
import { CurrentResponseDto } from './dto/current-response.dto';
import { GetCurrentDto } from './dto/get-current.dto';
import { CurrentService } from './current.service';
type CurrentSseMessage = {
data: CurrentResponseDto;
};
type CurrentSnapshot = {
signature: string;
response: CurrentResponseDto;
};
const DEFAULT_CURRENT_EVENTS_POLL_MS = 1_000;
@Injectable()
export class CurrentEventsService {
constructor(
private readonly current: CurrentService,
private readonly config: ConfigService,
) {}
/** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */
stream(query: GetCurrentDto): Observable<CurrentSseMessage> {
const pollMs = Math.max(
getIntegerConfig(this.config, 'CURRENT_EVENTS_POLL_MS', DEFAULT_CURRENT_EVENTS_POLL_MS),
250,
);
return timer(0, pollMs).pipe(
switchMap(() => from(this.createSnapshot(query))),
distinctUntilChanged((previous, current) => previous.signature === current.signature),
map((snapshot) => ({ data: snapshot.response })),
);
}
/** Собирает стабильную подпись ответа, чтобы не слать одинаковые SSE-события повторно. */
private async createSnapshot(query: GetCurrentDto): Promise<CurrentSnapshot> {
const response = await this.current.findByEdge(query);
return {
response,
signature: JSON.stringify(response),
};
}
}

View File

@@ -0,0 +1,30 @@
import { Controller, Get, Header, Query, Sse } from '@nestjs/common';
import { Observable } from 'rxjs';
import { CurrentEventsService } from './current-events.service';
import { CurrentService } from './current.service';
import { CurrentResponseDto } from './dto/current-response.dto';
import { GetCurrentDto } from './dto/get-current.dto';
@Controller('current')
export class CurrentController {
constructor(
private readonly current: CurrentService,
private readonly currentEvents: CurrentEventsService,
) {}
/** Возвращает последние известные значения по одному edge с опциональным фильтром тегов. */
@Get()
@Header('Cache-Control', 'no-store')
@Header('Pragma', 'no-cache')
@Header('Expires', '0')
findByEdge(@Query() query: GetCurrentDto) {
return this.current.findByEdge(query);
}
/** Открывает SSE-поток текущих значений по edge с отправкой только изменившихся снимков. */
@Sse('events')
@Header('Cache-Control', 'no-store')
events(@Query() query: GetCurrentDto): Observable<{ data: CurrentResponseDto }> {
return this.currentEvents.stream(query);
}
}

View File

@@ -0,0 +1,24 @@
import { CurrentResponseDto } from './dto/current-response.dto';
import { CurrentRow } from './current.types';
/** Преобразует строки БД current в стабильный DTO для API. */
export function createCurrentResponse(edge: string, rows: CurrentRow[]): CurrentResponseDto {
return {
edge,
items: rows.map((row) => ({
edge: row.edge,
tag: row.tag,
value: row.value,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
time: row.updatedAt,
name: row.name,
tagGroup: row.tag_group,
min: row.min,
max: row.max,
comment: row.comment,
unitOfMeasurement: row.unit_of_measurement,
precision: row.precision,
})),
};
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CurrentController } from './current.controller';
import { CurrentEventsService } from './current-events.service';
import { CurrentRepository } from './current.repository';
import { CurrentService } from './current.service';
@Module({
controllers: [CurrentController],
providers: [CurrentService, CurrentEventsService, CurrentRepository],
})
export class CurrentModule {}

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { DbService } from '../db/db.service';
import { CurrentRow } from './current.types';
@Injectable()
export class CurrentRepository {
constructor(private readonly db: DbService) {}
/** Читает текущие значения и добавляет метаданные тегов для UI-дашбордов. */
async findByEdge(edge: string, tags: string[] | null): Promise<CurrentRow[]> {
const result = await this.db.query<CurrentRow>(
`
SELECT
c.edge,
c.tag,
c.value,
c."createdAt",
c."updatedAt",
t.name,
t.tag_group,
t.min,
t.max,
t.comment,
t.unit_of_measurement,
t.precision
FROM current AS c
LEFT JOIN tag AS t
ON t.id = c.tag
WHERE c.edge = $1
AND ($2::varchar[] IS NULL OR c.tag = ANY($2::varchar[]))
ORDER BY c.tag ASC
`,
[edge, tags],
);
return result.rows;
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { normalizeRequiredText } from '../common/normalize-text';
import { CurrentResponseDto } from './dto/current-response.dto';
import { GetCurrentDto } from './dto/get-current.dto';
import { createCurrentResponse } from './current.mapper';
import { CurrentRepository } from './current.repository';
@Injectable()
export class CurrentService {
constructor(private readonly repository: CurrentRepository) {}
/** Нормализует фильтры и возвращает текущие значения по одному edge. */
async findByEdge(query: GetCurrentDto): Promise<CurrentResponseDto> {
const edge = normalizeRequiredText(query.edge, 'edge');
const tags = query.tags?.length ? query.tags : null;
const rows = await this.repository.findByEdge(edge, tags);
return createCurrentResponse(edge, rows);
}
}

View File

@@ -0,0 +1,14 @@
export type CurrentRow = {
edge: string;
tag: string;
value: number;
createdAt: Date;
updatedAt: Date;
name: string | null;
tag_group: string | null;
min: number | null;
max: number | null;
comment: string | null;
unit_of_measurement: string | null;
precision: number | null;
};

View File

@@ -0,0 +1,20 @@
export type CurrentItemDto = {
edge: string;
tag: string;
value: number;
createdAt: Date;
updatedAt: Date;
time: Date;
name: string | null;
tagGroup: string | null;
min: number | null;
max: number | null;
comment: string | null;
unitOfMeasurement: string | null;
precision: number | null;
};
export type CurrentResponseDto = {
edge: string;
items: CurrentItemDto[];
};

View File

@@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { parseCommaSeparatedList } from '../../common/query-list';
export class GetCurrentDto {
@IsNotEmpty()
@IsString()
edge!: string;
@IsOptional()
@Transform(({ value }) => parseCommaSeparatedList(value))
@IsArray()
@IsString({ each: true })
tags?: string[];
}