fix(gps): отсев устаревших копий и LBS-телепортов (stale/jump фильтры), настройка maxJumpSpeedKmh, фильтрация трека при выдаче

This commit is contained in:
2026-08-25 17:49:38 +03:00
parent b635d1e16e
commit 3d24a15fb3
8 changed files with 189 additions and 38 deletions

View File

@@ -52,19 +52,71 @@ export interface ProcessPositionInput {
recordedAt?: Date;
}
// Вердикт фильтрации точки относительно предыдущей принятой
export type PointVerdict = "accept" | "stale" | "jump";
export interface GpsFilterSettings {
jitterMeters: number;
maxJumpSpeedKmh: number;
}
interface PrevPoint {
lat: number;
lng: number;
recordedAt: Date;
}
/**
* Классификация новой точки относительно предыдущей ПРИНЯТОЙ.
* Используется и в processPosition (при приёме), и при отдаче трека.
*
* - stale: recordedAt <= prev.recordedAt — устаревшая копия/дубликат
* (трекеры без GPS-фикса пересылают старый фикс со старым fixTime).
* - jump: impliedKmh = dist/dt > maxJumpSpeedKmh — нереалистичный скачок
* (LBS-позиции в десятках км от реальных); 0 = фильтр выключен.
* Защита от dt <= 0 (после stale-проверки недостижимо, но на всякий случай):
* считаем jump'ом, если dist > jitterMeters.
*/
export function classifyPoint(
prev: PrevPoint | null,
point: PrevPoint,
settings: GpsFilterSettings
): PointVerdict {
if (!prev) return "accept";
if (point.recordedAt.getTime() <= prev.recordedAt.getTime()) {
return "stale";
}
if (settings.maxJumpSpeedKmh > 0) {
const dtSec = (point.recordedAt.getTime() - prev.recordedAt.getTime()) / 1000;
const dist = haversineMeters(prev.lat, prev.lng, point.lat, point.lng);
if (dtSec <= 0) {
if (dist > settings.jitterMeters) return "jump";
} else {
const impliedKmh = dist / 1000 / (dtSec / 3600);
if (impliedKmh > settings.maxJumpSpeedKmh) return "jump";
}
}
return "accept";
}
/**
* Основная точка входа: сохраняет позицию, обновляет last_* объекта,
* проверяет геозоны и рассылает SSE/уведомления.
*
* Семантика времени:
* - last_seen_at = ВСЕГДА время ПРИЁМА сервером (онлайн-индикатор на карте);
* - last_recorded_at = время точки по устройству (fixTime трекера);
* - last_recorded_at = время последней ПРИНЯТОЙ точки по устройству (fixTime);
* - gps_positions.recorded_at = время устройства (для треков).
*
* Фильтр джиттера: если в настройках jitterMeters > 0, у объекта есть
* предыдущая точка, расстояние до неё < jitterMeters и скорость мала
* (null или < 5 км/ч) — точка отбрасывается: не пишется в gps_positions,
* координаты и геозоны не трогаются, но last_seen_at/is_online обновляются.
* Цепочка фильтров (сравнение всегда с последней принятой точкой):
* 1. stale — устаревшая копия/дубликат по времени устройства;
* 2. jump — нереалистичный скачок скорости (LBS-позиции);
* 3. jitter — мелкое перемещение с малой скоростью.
* Отброшенная точка не пишется в gps_positions, координаты и геозоны не
* трогаются, но last_seen_at/is_online обновляются (устройство живо).
*/
export async function processPosition(
assetId: number,
@@ -76,17 +128,39 @@ export async function processPosition(
const asset = await gpsStorage.getAsset(assetId, organizationId);
if (!asset) return;
// Фильтр джиттера (округление мелких перемещений)
const settings = await gpsStorage.getSettings(organizationId);
if (
settings.jitterMeters > 0 &&
asset.lastLat !== null &&
asset.lastLng !== null
) {
const distance = haversineMeters(asset.lastLat, asset.lastLng, input.lat, input.lng);
// Предыдущая принятая точка объекта (если есть)
const prev: PrevPoint | null =
asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null
? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt }
: null;
// Фильтры stale/jump (устаревшие копии и LBS-скачки)
const verdict = classifyPoint(prev, { lat: input.lat, lng: input.lng, recordedAt }, settings);
if (verdict !== "accept") {
if (verdict === "stale") {
console.log(
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
);
} else {
const dist = prev ? haversineMeters(prev.lat, prev.lng, input.lat, input.lng) : 0;
const dtSec = prev ? (recordedAt.getTime() - prev.recordedAt.getTime()) / 1000 : 0;
const impliedKmh = dtSec > 0 ? dist / 1000 / (dtSec / 3600) : Infinity;
console.log(
`[GPS] skipped jump: asset=${assetId} dist=${Math.round(dist)}m dt=${Math.round(dtSec)}s implied=${Math.round(impliedKmh)}km/h > max=${settings.maxJumpSpeedKmh}km/h`
);
}
// Устройство живо — фиксируем факт приёма, точку не сохраняем
await gpsStorage.touchAssetSeen(assetId);
return;
}
// Фильтр джиттера (округление мелких перемещений)
if (settings.jitterMeters > 0 && prev) {
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
const speed = input.speed ?? null;
if (distance < settings.jitterMeters && (speed === null || speed < 5)) {
// Устройство живо — фиксируем факт приёма, точку не сохраняем
await gpsStorage.touchAssetSeen(assetId);
return;
}

View File

@@ -2,7 +2,7 @@ import { Router } from "express";
import type { Express, Response } from "express";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { gpsStorage } from "./storage";
import { processPosition } from "./geozone.service";
import { processPosition, classifyPoint } from "./geozone.service";
import { ensureDevice, deleteDevice } from "./traccar.client";
import { storage } from "../storage";
@@ -173,16 +173,28 @@ export function registerGpsRoutes(app: Express) {
router.get("/settings", async (req: AuthenticatedRequest, res: Response) => {
const settings = await gpsStorage.getSettings(req.user!.organizationId);
res.json({ success: true, jitterMeters: settings.jitterMeters });
res.json({ success: true, ...settings });
});
router.put("/settings", async (req: AuthenticatedRequest, res: Response) => {
const jitterMeters = Number(req.body?.jitterMeters);
// Обновляем только переданные поля (обратная совместимость с клиентом,
// который шлёт только jitterMeters)
const current = await gpsStorage.getSettings(req.user!.organizationId);
const jitterMeters =
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
const maxJumpSpeedKmh =
req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) {
return badRequest(res, "jitterMeters: целое число от 0 до 1000");
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, jitterMeters);
res.json({ success: true, jitterMeters: row.jitterMeters });
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh });
res.json({ success: true, jitterMeters: row.jitterMeters, maxJumpSpeedKmh: row.maxJumpSpeedKmh });
});
// =====================
@@ -306,19 +318,36 @@ export function registerGpsRoutes(app: Express) {
const all = await gpsStorage.getTrack(id, orgId, from ?? undefined, to ?? undefined);
const total = all.length;
let points = all;
// Фильтрация сохранённых данных при выдаче (без удаления из БД):
// пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила,
// что при приёме, сравнение с предыдущей принятой точкой
const settings = await gpsStorage.getSettings(orgId);
const filtered: typeof all = [];
let filteredCount = 0;
let prevAccepted: { lat: number; lng: number; recordedAt: Date } | null = null;
for (const point of all) {
const current = { lat: point.lat, lng: point.lng, recordedAt: point.recordedAt };
if (classifyPoint(prevAccepted, current, settings) === "accept") {
filtered.push(point);
prevAccepted = current;
} else {
filteredCount++;
}
}
let points = filtered;
let sampled = false;
if (total > MAX_TRACK_POINTS) {
const step = Math.ceil(total / MAX_TRACK_POINTS);
points = all.filter((_, idx) => idx % step === 0);
if (points.length > MAX_TRACK_POINTS) {
const step = Math.ceil(points.length / MAX_TRACK_POINTS);
points = points.filter((_, idx) => idx % step === 0);
// Всегда включаем последнюю точку трека
if (points[points.length - 1] !== all[total - 1]) {
points.push(all[total - 1]);
if (points[points.length - 1] !== filtered[filtered.length - 1]) {
points.push(filtered[filtered.length - 1]);
}
sampled = true;
}
res.json({ success: true, points, total, sampled });
res.json({ success: true, points, total, filteredCount, sampled });
});
// =====================

View File

@@ -461,22 +461,28 @@ export class GpsStorage {
// Настройки организации
// =====================
/** Настройки GPS организации; если строки нет — дефолт */
async getSettings(organizationId: number): Promise<{ jitterMeters: number }> {
/** Настройки GPS организации; если строки нет — дефолты */
async getSettings(organizationId: number): Promise<{ jitterMeters: number; maxJumpSpeedKmh: number }> {
const [row] = await db
.select()
.from(gpsSettings)
.where(eq(gpsSettings.organizationId, organizationId));
return { jitterMeters: row?.jitterMeters ?? 25 };
return {
jitterMeters: row?.jitterMeters ?? 25,
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
};
}
async upsertSettings(organizationId: number, jitterMeters: number): Promise<GpsSettings> {
async upsertSettings(
organizationId: number,
settings: { jitterMeters: number; maxJumpSpeedKmh: number }
): Promise<GpsSettings> {
const [row] = await db
.insert(gpsSettings)
.values({ organizationId, jitterMeters, updatedAt: new Date() })
.values({ organizationId, ...settings, updatedAt: new Date() })
.onConflictDoUpdate({
target: gpsSettings.organizationId,
set: { jitterMeters, updatedAt: new Date() },
set: { ...settings, updatedAt: new Date() },
})
.returning();
return row;