fix(gps): отсев устаревших копий и LBS-телепортов (stale/jump фильтры), настройка maxJumpSpeedKmh, фильтрация трека при выдаче
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
// =====================
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user