feat: المجموعة H — المواقع في Redis، والقاعدة من الـworker فقط
كان كل نبضة موقع (كل 1-3 ثوانٍ لكل سائق متصل) تكلّف استعلاماً + كتابة صف السائق في Postgres — أثقل حمل كان على القاعدة، وأثقل حتى من سيرو. - H1/H2: نبضة الموقع = Redis فقط، بعتبات سيرو (10م / 1.0 سرعة / 5° اتجاه). سائق واقف = EXPIRE واحد: لا كتابة، لا نقطة مسار، ولا بثّ للراكب - H3: لقطة آخر موقع على drivers (heading/speed/loc_status/loc_updated_at) يكتبها الـworker دورياً. لم نُنشئ جدولاً منفصلاً — drivers أصلاً صف واحد لكل سائق - H4: driver_tracks — تتراكم في قائمة Redis ويُدرجها الـworker بعبارة واحدة - H5: فهرسا available/busy — البحث يمسح المتاحين فقط. السائق يصير busy عند القبول ويعود available عند الإنهاء/الإلغاء - H8: المطابقة والاحتيال و«الرحلات المتاحة» تقرأ الموقع الحيّ من Redis. كشف تزوير الوصول خاصةً يجب ألّا يحكم بلقطة دورية الـworker صار حقيقياً (كان setInterval فارغاً): سياق Nest مستقل، تفريغ كل 5s، قفل يمنع تراكب الدورات، وتفريغة أخيرة عند SIGTERM. مؤجَّل من H: H6 (سلوك السائق) و H7 (ساعات العمل) — تحليلات تُبنى من driver_tracks لاحقاً. هجرة: DriverLocations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a83dd7e5e5
commit
0ed3a3160a
@@ -14,6 +14,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { MatchingModule } from './modules/matching/matching.module';
|
||||
import { LocationsModule } from './modules/locations/locations.module';
|
||||
import { TariffModule } from './modules/tariff/tariff.module';
|
||||
import { MapsModule } from './modules/maps/maps.module';
|
||||
import { TripsModule } from './modules/trips/trips.module';
|
||||
@@ -69,6 +70,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
|
||||
AuthModule,
|
||||
MapsModule,
|
||||
MatchingModule,
|
||||
LocationsModule,
|
||||
TariffModule,
|
||||
RideTypesModule,
|
||||
DriversModule,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* المواقع (docs/17 — H3/H4):
|
||||
* - أعمدة لقطة الموقع على `drivers` — يكتبها الـworker دورياً، لا كل نبضة.
|
||||
* - `driver_tracks` — نقاط المسار التاريخية، إدراج مجمّع من الـworker.
|
||||
*/
|
||||
export class DriverLocations1721830000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS heading real`);
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS speed real`);
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_status varchar`);
|
||||
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_updated_at timestamptz`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_driver_tracks (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
lat double precision NOT NULL,
|
||||
lng double precision NOT NULL,
|
||||
heading real,
|
||||
speed real,
|
||||
status varchar,
|
||||
recorded_at timestamptz NOT NULL
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time"
|
||||
ON tripz_driver_tracks (tenant_id, driver_id, recorded_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_driver_tracks`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_updated_at`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_status`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS speed`);
|
||||
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS heading`);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,18 @@ export class DriversController {
|
||||
@Post('location')
|
||||
location(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body('lat') lat: number,
|
||||
@Body('lng') lng: number,
|
||||
@Body() body: { lat: number; lng: number; heading?: number; speed?: number },
|
||||
) {
|
||||
return this.drivers.updateLocation(user.tenantId, user.userId, Number(lat), Number(lng));
|
||||
return this.drivers.updateLocation(
|
||||
user.tenantId,
|
||||
user.userId,
|
||||
Number(body.lat),
|
||||
Number(body.lng),
|
||||
{
|
||||
heading: body.heading == null ? undefined : Number(body.heading),
|
||||
speed: body.speed == null ? undefined : Number(body.speed),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// للأدمن/المشغّل — تبسيط P1: أي مستخدم مصادَق؛ يُقيَّد بدور لاحقاً.
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Driver } from './entities/driver.entity';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { DriversController } from './drivers.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { MatchingModule } from '../matching/matching.module';
|
||||
import { LocationsModule } from '../locations/locations.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Driver]), UsersModule, MatchingModule],
|
||||
imports: [TypeOrmModule.forFeature([Driver]), UsersModule, LocationsModule],
|
||||
controllers: [DriversController],
|
||||
providers: [DriversService],
|
||||
exports: [DriversService],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Driver } from './entities/driver.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { MatchingService } from '../matching/matching.service';
|
||||
import { DriverLocationService } from '../locations/driver-location.service';
|
||||
|
||||
@Injectable()
|
||||
export class DriversService {
|
||||
@@ -11,7 +11,7 @@ export class DriversService {
|
||||
@InjectRepository(Driver)
|
||||
private readonly repo: Repository<Driver>,
|
||||
private readonly users: UsersService,
|
||||
private readonly matching: MatchingService,
|
||||
private readonly locations: DriverLocationService,
|
||||
) {}
|
||||
|
||||
findByUser(tenantId: string, userId: string): Promise<Driver | null> {
|
||||
@@ -62,7 +62,7 @@ export class DriversService {
|
||||
return this.repo.save(driver);
|
||||
}
|
||||
|
||||
/** يبدّل حالة الاتصال؛ عند الاتصال يُضاف لفهرس GEO، وعند الفصل يُزال. */
|
||||
/** يبدّل حالة الاتصال؛ عند الاتصال يدخل فهرس المتاحين، وعند الفصل يُزال. */
|
||||
async setOnline(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
@@ -72,35 +72,42 @@ export class DriversService {
|
||||
if (!driver) throw new NotFoundException('Driver profile not found');
|
||||
driver.is_online = online;
|
||||
await this.repo.save(driver);
|
||||
if (!online) {
|
||||
await this.matching.removeDriver(tenantId, driver.service_class, driver.id);
|
||||
} else if (driver.last_lat != null && driver.last_lng != null) {
|
||||
await this.matching.addDriver(
|
||||
tenantId,
|
||||
driver.service_class,
|
||||
driver.id,
|
||||
driver.last_lat,
|
||||
driver.last_lng,
|
||||
);
|
||||
}
|
||||
await this.locations.setAvailability(
|
||||
tenantId,
|
||||
driver.id,
|
||||
driver.service_class,
|
||||
online ? 'available' : 'off',
|
||||
);
|
||||
return driver;
|
||||
}
|
||||
|
||||
/** تحديث الموقع الجاري: Redis GEO + لقطة في Postgres. */
|
||||
/**
|
||||
* نبضة موقع — **Redis فقط** (docs/17 — H1). كانت تكتب صف السائق في Postgres
|
||||
* على كل نبضة (كل ثانية/ثلاث لكل سائق متصل) — أثقل حمل كان على القاعدة.
|
||||
* اللقطة الدائمة يكتبها الـworker دورياً.
|
||||
*/
|
||||
async updateLocation(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
): Promise<{ ok: boolean }> {
|
||||
extra: { heading?: number; speed?: number } = {},
|
||||
): Promise<{ ok: boolean; significant: boolean }> {
|
||||
const driver = await this.findByUser(tenantId, userId);
|
||||
if (!driver) throw new NotFoundException('Driver profile not found');
|
||||
driver.last_lat = lat;
|
||||
driver.last_lng = lng;
|
||||
await this.repo.save(driver);
|
||||
if (driver.is_online) {
|
||||
await this.matching.addDriver(tenantId, driver.service_class, driver.id, lat, lng);
|
||||
}
|
||||
return { ok: true };
|
||||
if (!driver.is_online) return { ok: true, significant: false };
|
||||
|
||||
const res = await this.locations.update(tenantId, driver.id, driver.service_class, {
|
||||
lat,
|
||||
lng,
|
||||
heading: extra.heading,
|
||||
speed: extra.speed,
|
||||
});
|
||||
return { ok: true, significant: res.significant };
|
||||
}
|
||||
|
||||
/** الموقع الحيّ للسائق — من Redis، واللقطة احتياط. */
|
||||
liveLocation(tenantId: string, driverId: string) {
|
||||
return this.locations.get(tenantId, driverId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,27 @@ export class Driver {
|
||||
@Column({ type: 'numeric', precision: 3, scale: 2, default: 5 })
|
||||
rating: number;
|
||||
|
||||
// ---- لقطة الموقع (مكافئ `car_locations` عند سيرو) ----
|
||||
// الموقع الحيّ يعيش في Redis؛ هذه الأعمدة **يكتبها الـworker دورياً فقط**،
|
||||
// لا الطلب. لا تقرأها للحيّ — استخدم DriverLocationService (docs/17 — H1/H3).
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
last_lat: number;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
last_lng: number;
|
||||
|
||||
@Column({ type: 'real', nullable: true })
|
||||
heading: number | null;
|
||||
|
||||
@Column({ type: 'real', nullable: true })
|
||||
speed: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
loc_status: string | null; // available | busy | off
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
loc_updated_at: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
|
||||
@@ -70,19 +70,20 @@ export class FraudService {
|
||||
}
|
||||
}
|
||||
|
||||
/** يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً. */
|
||||
/**
|
||||
* يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً.
|
||||
* `driverAt` هو الموقع **الحيّ** من Redis — لا لقطة القاعدة الدورية، وإلا
|
||||
* حكمنا على تزوير الوصول بموقع عمره ثوانٍ (docs/17 — H1).
|
||||
*/
|
||||
async checkArrivedProximity(
|
||||
tenantId: string,
|
||||
driverUserId: string,
|
||||
driver: { last_lat?: number | null; last_lng?: number | null },
|
||||
driverAt: { lat: number; lng: number } | null,
|
||||
pickup: { lat: number; lng: number },
|
||||
tripId: string,
|
||||
) {
|
||||
if (driver.last_lat == null || driver.last_lng == null) return;
|
||||
const km = MapsService.haversineKm(
|
||||
{ lat: driver.last_lat, lng: driver.last_lng },
|
||||
pickup,
|
||||
);
|
||||
if (!driverAt) return;
|
||||
const km = MapsService.haversineKm(driverAt, pickup);
|
||||
if (km * 1000 > this.ARRIVED_MAX_M) {
|
||||
await this.flag(
|
||||
tenantId,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import RedisMock from 'ioredis-mock';
|
||||
import { DriverLocationService } from './driver-location.service';
|
||||
import { MatchingService } from '../matching/matching.service';
|
||||
|
||||
const TENANT = 't1';
|
||||
const DRIVER = 'd1';
|
||||
const CLASS = 'economy';
|
||||
|
||||
/** نقطة في وسط عمّان + إزاحة بالأمتار (تقريب كافٍ للاختبار). */
|
||||
const BASE = { lat: 31.9539, lng: 35.9106 };
|
||||
const metersToLat = (m: number) => m / 111_320;
|
||||
|
||||
describe('DriverLocationService', () => {
|
||||
let redis: any;
|
||||
let matching: MatchingService;
|
||||
let svc: DriverLocationService;
|
||||
let driversRepo: any;
|
||||
|
||||
beforeEach(() => {
|
||||
redis = new RedisMock({ keyPrefix: 'tripz:' });
|
||||
matching = new MatchingService(redis);
|
||||
driversRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
svc = new DriverLocationService(redis, driversRepo, matching);
|
||||
});
|
||||
|
||||
it('أول نبضة ذات دلالة وتُخزَّن', async () => {
|
||||
const r = await svc.update(TENANT, DRIVER, CLASS, BASE);
|
||||
expect(r.significant).toBe(true);
|
||||
|
||||
const pos = await svc.get(TENANT, DRIVER);
|
||||
expect(pos!.lat).toBeCloseTo(BASE.lat);
|
||||
expect(pos!.status).toBe('available');
|
||||
});
|
||||
|
||||
it('سائق واقف: النبضة التالية غير ذات دلالة ولا تضيف مساراً', async () => {
|
||||
await svc.update(TENANT, DRIVER, CLASS, BASE);
|
||||
const before = await redis.llen('loc:tracks');
|
||||
|
||||
// إزاحة مترين فقط — تحت عتبة 10م
|
||||
const r = await svc.update(TENANT, DRIVER, CLASS, {
|
||||
lat: BASE.lat + metersToLat(2),
|
||||
lng: BASE.lng,
|
||||
});
|
||||
|
||||
expect(r.significant).toBe(false);
|
||||
expect(await redis.llen('loc:tracks')).toBe(before);
|
||||
});
|
||||
|
||||
it('تحرّك أكثر من 10م = نبضة ذات دلالة + نقطة مسار', async () => {
|
||||
await svc.update(TENANT, DRIVER, CLASS, BASE);
|
||||
const before = await redis.llen('loc:tracks');
|
||||
|
||||
const r = await svc.update(TENANT, DRIVER, CLASS, {
|
||||
lat: BASE.lat + metersToLat(25),
|
||||
lng: BASE.lng,
|
||||
});
|
||||
|
||||
expect(r.significant).toBe(true);
|
||||
expect(await redis.llen('loc:tracks')).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('تغيّر السرعة وحده يكفي رغم الوقوف', async () => {
|
||||
await svc.update(TENANT, DRIVER, CLASS, { ...BASE, speed: 0 });
|
||||
const r = await svc.update(TENANT, DRIVER, CLASS, { ...BASE, speed: 12 });
|
||||
expect(r.significant).toBe(true);
|
||||
});
|
||||
|
||||
it('السائق «متّسخ» بعد نبضة ذات دلالة — ليكتبه الـworker لاحقاً', async () => {
|
||||
await svc.update(TENANT, DRIVER, CLASS, BASE);
|
||||
expect(await redis.smembers(DriverLocationService.DIRTY_KEY)).toContain(`${TENANT}|${DRIVER}`);
|
||||
});
|
||||
|
||||
it('المتاح يظهر في المطابقة، والمشغول يختفي منها', async () => {
|
||||
await svc.update(TENANT, DRIVER, CLASS, BASE);
|
||||
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(1);
|
||||
|
||||
await svc.setAvailability(TENANT, DRIVER, CLASS, 'busy');
|
||||
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
|
||||
|
||||
await svc.setAvailability(TENANT, DRIVER, CLASS, 'available');
|
||||
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('الفصل (off) يزيله من الفهرس ومن Redis', async () => {
|
||||
await svc.update(TENANT, DRIVER, CLASS, BASE);
|
||||
await svc.setAvailability(TENANT, DRIVER, CLASS, 'off');
|
||||
|
||||
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
|
||||
expect(await svc.get(TENANT, DRIVER)).toBeNull();
|
||||
});
|
||||
|
||||
it('عند غياب المفتاح يرجع للقطة القاعدة', async () => {
|
||||
driversRepo.findOne.mockResolvedValue({
|
||||
last_lat: 31.9,
|
||||
last_lng: 35.9,
|
||||
heading: 90,
|
||||
speed: 0,
|
||||
loc_status: 'available',
|
||||
service_class: CLASS,
|
||||
loc_updated_at: new Date(),
|
||||
});
|
||||
|
||||
const pos = await svc.get(TENANT, 'cold-driver');
|
||||
expect(pos!.lat).toBe(31.9);
|
||||
expect(driversRepo.findOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('المستأجرون معزولون في المطابقة', async () => {
|
||||
await svc.update('tenant-a', DRIVER, CLASS, BASE);
|
||||
expect(await matching.findNearby('tenant-b', CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../../common/redis/redis.module';
|
||||
import { MatchingService, DriverAvailability } from '../matching/matching.service';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
|
||||
/** الموقع الحيّ كما يعيش في Redis. */
|
||||
export interface LivePosition {
|
||||
lat: number;
|
||||
lng: number;
|
||||
heading: number;
|
||||
speed: number;
|
||||
status: DriverAvailability;
|
||||
serviceClass: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface LocationPing {
|
||||
lat: number;
|
||||
lng: number;
|
||||
heading?: number;
|
||||
speed?: number;
|
||||
status?: DriverAvailability;
|
||||
}
|
||||
|
||||
/**
|
||||
* عتبات مأخوذة من `driver_socket.php` عند سيرو — سائق واقف يرسل كل ثانية
|
||||
* يجب ألّا يكلّف شيئاً تقريباً.
|
||||
*/
|
||||
const MIN_MOVE_M = 10; // أقل من هذا = لا GEOADD ولا نقطة مسار
|
||||
const SPEED_DELTA = 1.0;
|
||||
const HEADING_DELTA = 5;
|
||||
const LOC_TTL_SEC = 300; // سائق صامت 5 دقائق يسقط من الفهرس
|
||||
const MAX_TRACK_BUFFER = 50_000; // سقف أمان لطابور المسارات
|
||||
|
||||
@Injectable()
|
||||
export class DriverLocationService {
|
||||
private readonly logger = new Logger('DriverLocation');
|
||||
|
||||
/** طابور المسارات ومجموعة «المتّسخين» — يستهلكهما الـworker (docs/17 H3/H4). */
|
||||
static readonly DIRTY_KEY = 'loc:dirty';
|
||||
static readonly TRACKS_KEY = 'loc:tracks';
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
@InjectRepository(Driver) private readonly drivers: Repository<Driver>,
|
||||
private readonly matching: MatchingService,
|
||||
) {}
|
||||
|
||||
private key(tenantId: string, driverId: string): string {
|
||||
return `driver:loc:${tenantId}:${driverId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* نبضة موقع من السائق — **Redis فقط، لا قاعدة** (docs/17 — H1/H2).
|
||||
* يرجع `true` إن كانت النبضة ذات دلالة (تحرّك/تغيّر فعلي) — عندها فقط
|
||||
* تُحدَّث الفهارس ويُسجَّل أثر، ويعرف المُنادي أن البثّ يستحق.
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
serviceClass: string,
|
||||
ping: LocationPing,
|
||||
): Promise<{ significant: boolean; position: LivePosition }> {
|
||||
const k = this.key(tenantId, driverId);
|
||||
const prev = await this.readHash(k);
|
||||
|
||||
const heading = ping.heading ?? prev?.heading ?? 0;
|
||||
const speed = ping.speed ?? prev?.speed ?? 0;
|
||||
const status = ping.status ?? prev?.status ?? 'available';
|
||||
|
||||
const movedM = prev
|
||||
? MapsService.haversineKm({ lat: prev.lat, lng: prev.lng }, { lat: ping.lat, lng: ping.lng }) * 1000
|
||||
: Infinity;
|
||||
|
||||
const significant =
|
||||
!prev ||
|
||||
movedM >= MIN_MOVE_M ||
|
||||
Math.abs(speed - prev.speed) >= SPEED_DELTA ||
|
||||
Math.abs(heading - prev.heading) >= HEADING_DELTA ||
|
||||
status !== prev.status;
|
||||
|
||||
const position: LivePosition = {
|
||||
lat: ping.lat,
|
||||
lng: ping.lng,
|
||||
heading,
|
||||
speed,
|
||||
status,
|
||||
serviceClass,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (!significant) {
|
||||
// واقف مكانه: نجدّد العمر فقط حتى لا يسقط من الفهرس. لا كتابة، لا مسار.
|
||||
await this.redis.expire(k, LOC_TTL_SEC);
|
||||
return { significant: false, position: prev! };
|
||||
}
|
||||
|
||||
const pipe = this.redis.multi();
|
||||
pipe.hset(k, {
|
||||
lat: String(position.lat),
|
||||
lng: String(position.lng),
|
||||
heading: String(heading),
|
||||
speed: String(speed),
|
||||
status,
|
||||
serviceClass,
|
||||
updatedAt: String(position.updatedAt),
|
||||
});
|
||||
pipe.expire(k, LOC_TTL_SEC);
|
||||
// «متّسخ» = يحتاج كتابة لقطة على القاعدة لاحقاً (مجموعة → لا تكرار).
|
||||
pipe.sadd(DriverLocationService.DIRTY_KEY, `${tenantId}|${driverId}`);
|
||||
// نقطة مسار — تُدرَج دفعةً واحدة من الـworker.
|
||||
pipe.rpush(
|
||||
DriverLocationService.TRACKS_KEY,
|
||||
JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
driver_id: driverId,
|
||||
lat: position.lat,
|
||||
lng: position.lng,
|
||||
heading,
|
||||
speed,
|
||||
status,
|
||||
at: position.updatedAt,
|
||||
}),
|
||||
);
|
||||
pipe.ltrim(DriverLocationService.TRACKS_KEY, -MAX_TRACK_BUFFER, -1);
|
||||
await pipe.exec();
|
||||
|
||||
await this.matching.setPosition(tenantId, serviceClass, driverId, ping.lat, ping.lng, status);
|
||||
return { significant: true, position };
|
||||
}
|
||||
|
||||
/**
|
||||
* الموقع الحيّ — Redis خط أول، واللقطة في القاعدة احتياط عند غياب المفتاح
|
||||
* (سائق لم يرسل منذ 5 دقائق، أو Redis أُعيد تشغيله).
|
||||
*/
|
||||
async get(tenantId: string, driverId: string): Promise<LivePosition | null> {
|
||||
const live = await this.readHash(this.key(tenantId, driverId));
|
||||
if (live) return live;
|
||||
|
||||
const row = await this.drivers.findOne({
|
||||
where: { tenant_id: tenantId, id: driverId },
|
||||
select: {
|
||||
last_lat: true,
|
||||
last_lng: true,
|
||||
heading: true,
|
||||
speed: true,
|
||||
loc_status: true,
|
||||
service_class: true,
|
||||
loc_updated_at: true,
|
||||
},
|
||||
});
|
||||
if (!row || row.last_lat == null || row.last_lng == null) return null;
|
||||
return {
|
||||
lat: Number(row.last_lat),
|
||||
lng: Number(row.last_lng),
|
||||
heading: Number(row.heading ?? 0),
|
||||
speed: Number(row.speed ?? 0),
|
||||
status: (row.loc_status ?? 'off') as DriverAvailability,
|
||||
serviceClass: row.service_class,
|
||||
updatedAt: row.loc_updated_at ? row.loc_updated_at.getTime() : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** يبدّل توفّر السائق (متاح/مشغول) وينقله بين الفهرسين (docs/17 — H5). */
|
||||
async setAvailability(
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
serviceClass: string,
|
||||
status: DriverAvailability,
|
||||
): Promise<void> {
|
||||
const k = this.key(tenantId, driverId);
|
||||
const pos = await this.readHash(k);
|
||||
if (status === 'off') {
|
||||
await this.redis.del(k);
|
||||
await this.matching.removeDriver(tenantId, serviceClass, driverId);
|
||||
return;
|
||||
}
|
||||
await this.redis.multi().hset(k, 'status', status).expire(k, LOC_TTL_SEC).exec();
|
||||
if (pos) {
|
||||
await this.matching.setPosition(tenantId, serviceClass, driverId, pos.lat, pos.lng, status);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- داخلي ----
|
||||
|
||||
private async readHash(k: string): Promise<LivePosition | null> {
|
||||
const h = await this.redis.hgetall(k);
|
||||
if (!h?.lat) return null;
|
||||
return {
|
||||
lat: Number(h.lat),
|
||||
lng: Number(h.lng),
|
||||
heading: Number(h.heading ?? 0),
|
||||
speed: Number(h.speed ?? 0),
|
||||
status: (h.status ?? 'available') as DriverAvailability,
|
||||
serviceClass: h.serviceClass ?? 'economy',
|
||||
updatedAt: Number(h.updatedAt ?? 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* نقاط مسار السائق (مكافئ `car_tracks` عند سيرو) — للتتبع وفضّ النزاعات.
|
||||
* الجدول: tripz_driver_tracks. **يُكتب دفعةً واحدة من الـworker، لا من الطلب**
|
||||
* (docs/17 — H4).
|
||||
*/
|
||||
@Entity('driver_tracks')
|
||||
@Index(['tenant_id', 'driver_id', 'recorded_at'])
|
||||
export class DriverTrack {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
driver_id: string;
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
lat: number;
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
lng: number;
|
||||
|
||||
@Column({ type: 'real', nullable: true })
|
||||
heading: number | null;
|
||||
|
||||
@Column({ type: 'real', nullable: true })
|
||||
speed: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
status: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
recorded_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../../common/redis/redis.module';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
import { DriverTrack } from './entities/driver-track.entity';
|
||||
import { DriverLocationService } from './driver-location.service';
|
||||
|
||||
interface TrackPoint {
|
||||
tenant_id: string;
|
||||
driver_id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
heading: number;
|
||||
speed: number;
|
||||
status: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** حدود الدفعة — تحمي القاعدة من دفعة ضخمة بعد انقطاع طويل. */
|
||||
const MAX_DIRTY_PER_RUN = 500;
|
||||
const MAX_TRACKS_PER_RUN = 2_000;
|
||||
|
||||
/**
|
||||
* يفرّغ ما تراكم في Redis إلى القاعدة (docs/17 — H3/H4). يعمل في الـworker
|
||||
* على فترات، **لا في مسار الطلب**.
|
||||
*
|
||||
* الفكرة: آلاف النبضات تُكتب مرة واحدة لكل سائق في الدورة، ونقاط المسار
|
||||
* تُدرَج بعبارة واحدة بدل عبارة لكل نقطة.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocationFlusherService {
|
||||
private readonly logger = new Logger('LocationFlusher');
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
@InjectRepository(Driver) private readonly drivers: Repository<Driver>,
|
||||
@InjectRepository(DriverTrack) private readonly tracks: Repository<DriverTrack>,
|
||||
) {}
|
||||
|
||||
async flush(): Promise<{ snapshots: number; tracks: number }> {
|
||||
const [snapshots, tracksWritten] = await Promise.all([
|
||||
this.flushSnapshots(),
|
||||
this.flushTracks(),
|
||||
]);
|
||||
if (snapshots || tracksWritten) {
|
||||
this.logger.debug(`flushed snapshots=${snapshots} tracks=${tracksWritten}`);
|
||||
}
|
||||
return { snapshots, tracks: tracksWritten };
|
||||
}
|
||||
|
||||
/** لقطة آخر موقع لكل سائق «متّسخ» — صف واحد لكل سائق مهما كثرت نبضاته. */
|
||||
private async flushSnapshots(): Promise<number> {
|
||||
// SPOP يسحب ويحذف ذرّياً: نسخة worker أخرى لن تعالج نفس السائق.
|
||||
const members = await this.redis.spop(DriverLocationService.DIRTY_KEY, MAX_DIRTY_PER_RUN);
|
||||
const list = Array.isArray(members) ? members : members ? [members] : [];
|
||||
if (list.length === 0) return 0;
|
||||
|
||||
let written = 0;
|
||||
for (const member of list) {
|
||||
const [tenantId, driverId] = String(member).split('|');
|
||||
if (!tenantId || !driverId) continue;
|
||||
try {
|
||||
const h = await this.redis.hgetall(`driver:loc:${tenantId}:${driverId}`);
|
||||
if (!h?.lat) continue; // انتهى عمر المفتاح — لا شيء نكتبه
|
||||
await this.drivers.update(
|
||||
{ tenant_id: tenantId, id: driverId },
|
||||
{
|
||||
last_lat: Number(h.lat),
|
||||
last_lng: Number(h.lng),
|
||||
heading: Number(h.heading ?? 0),
|
||||
speed: Number(h.speed ?? 0),
|
||||
loc_status: h.status ?? null,
|
||||
loc_updated_at: new Date(Number(h.updatedAt ?? Date.now())),
|
||||
},
|
||||
);
|
||||
written++;
|
||||
} catch (e: any) {
|
||||
// إعادة السائق للمجموعة كي لا تضيع لقطته بسبب عطل عابر.
|
||||
await this.redis.sadd(DriverLocationService.DIRTY_KEY, member);
|
||||
this.logger.warn(`snapshot ${member} failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/** نقاط المسار — إدراج مجمّع بعبارة واحدة. */
|
||||
private async flushTracks(): Promise<number> {
|
||||
const raw = await this.redis.lpop(DriverLocationService.TRACKS_KEY, MAX_TRACKS_PER_RUN);
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
if (list.length === 0) return 0;
|
||||
|
||||
const rows: Partial<DriverTrack>[] = [];
|
||||
for (const item of list) {
|
||||
try {
|
||||
const p: TrackPoint = JSON.parse(item);
|
||||
rows.push({
|
||||
tenant_id: p.tenant_id,
|
||||
driver_id: p.driver_id,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
heading: p.heading,
|
||||
speed: p.speed,
|
||||
status: p.status,
|
||||
recorded_at: new Date(p.at),
|
||||
});
|
||||
} catch {
|
||||
this.logger.warn('skipped malformed track point');
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
try {
|
||||
await this.tracks.insert(rows);
|
||||
return rows.length;
|
||||
} catch (e: any) {
|
||||
// نُعيدها لرأس الطابور حتى لا يضيع المسار.
|
||||
await this.redis.lpush(DriverLocationService.TRACKS_KEY, ...list);
|
||||
this.logger.warn(`track insert failed, requeued ${list.length}: ${e?.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
import { DriverTrack } from './entities/driver-track.entity';
|
||||
import { DriverLocationService } from './driver-location.service';
|
||||
import { LocationFlusherService } from './location-flusher.service';
|
||||
import { MatchingModule } from '../matching/matching.module';
|
||||
|
||||
/**
|
||||
* وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و
|
||||
* TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule],
|
||||
providers: [DriverLocationService, LocationFlusherService],
|
||||
exports: [DriverLocationService, LocationFlusherService],
|
||||
})
|
||||
export class LocationsModule {}
|
||||
@@ -7,41 +7,59 @@ export interface NearbyDriver {
|
||||
distanceKm: number;
|
||||
}
|
||||
|
||||
/** توفّر السائق — يحدّد الفهرس الذي يعيش فيه (docs/17 — H5). */
|
||||
export type DriverAvailability = 'available' | 'busy' | 'off';
|
||||
|
||||
/**
|
||||
* المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل مستأجر:
|
||||
* geo:drivers:{tenantId} (ببادئة tripz: تلقائياً من ioredis).
|
||||
* نستخدم redis.call لتفادي تعقيد أنماط ioredis المتغيّرة.
|
||||
* المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل
|
||||
* (مستأجر × فئة خدمة × توفّر): geo:drivers:{tenantId}:{class}:{available|busy}
|
||||
* (ببادئة tripz: تلقائياً من ioredis).
|
||||
*
|
||||
* فصل available عن busy (نمط سيرو): البحث يمسح المتاحين فقط بدل أن يجلب
|
||||
* الجميع ثم يصفّي المشغولين.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MatchingService {
|
||||
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||
|
||||
// فهرس GEO لكل (مستأجر × فئة خدمة) — المطابقة تحترم نوع الرحلة.
|
||||
private key(tenantId: string, serviceClass: string): string {
|
||||
return `geo:drivers:${tenantId}:${serviceClass}`;
|
||||
private key(tenantId: string, serviceClass: string, status: 'available' | 'busy'): string {
|
||||
return `geo:drivers:${tenantId}:${serviceClass}:${status}`;
|
||||
}
|
||||
|
||||
async addDriver(
|
||||
/**
|
||||
* يضع السائق في الفهرس الموافق لحالته ويزيله من الآخر — عملية واحدة
|
||||
* تمنع بقاءه في الفهرسين معاً.
|
||||
*/
|
||||
async setPosition(
|
||||
tenantId: string,
|
||||
serviceClass: string,
|
||||
driverId: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
) {
|
||||
await this.redis.call(
|
||||
'GEOADD',
|
||||
this.key(tenantId, serviceClass),
|
||||
String(lng),
|
||||
String(lat),
|
||||
driverId,
|
||||
);
|
||||
status: DriverAvailability,
|
||||
): Promise<void> {
|
||||
if (status === 'off') {
|
||||
await this.removeDriver(tenantId, serviceClass, driverId);
|
||||
return;
|
||||
}
|
||||
const target = this.key(tenantId, serviceClass, status);
|
||||
const other = this.key(tenantId, serviceClass, status === 'available' ? 'busy' : 'available');
|
||||
await this.redis
|
||||
.multi()
|
||||
.geoadd(target, lng, lat, driverId)
|
||||
.zrem(other, driverId)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async removeDriver(tenantId: string, serviceClass: string, driverId: string) {
|
||||
await this.redis.call('ZREM', this.key(tenantId, serviceClass), driverId);
|
||||
async removeDriver(tenantId: string, serviceClass: string, driverId: string): Promise<void> {
|
||||
await this.redis
|
||||
.multi()
|
||||
.zrem(this.key(tenantId, serviceClass, 'available'), driverId)
|
||||
.zrem(this.key(tenantId, serviceClass, 'busy'), driverId)
|
||||
.exec();
|
||||
}
|
||||
|
||||
/** أقرب السائقين من نفس فئة الخدمة ضمن نصف قطر (كم). */
|
||||
/** أقرب السائقين **المتاحين** من نفس فئة الخدمة ضمن نصف قطر (كم). */
|
||||
async findNearby(
|
||||
tenantId: string,
|
||||
serviceClass: string,
|
||||
@@ -52,7 +70,7 @@ export class MatchingService {
|
||||
): Promise<NearbyDriver[]> {
|
||||
const res = (await this.redis.call(
|
||||
'GEOSEARCH',
|
||||
this.key(tenantId, serviceClass),
|
||||
this.key(tenantId, serviceClass, 'available'),
|
||||
'FROMLONLAT',
|
||||
String(lng),
|
||||
String(lat),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RealtimeModule } from '../../realtime/realtime.module';
|
||||
import { FraudModule } from '../fraud/fraud.module';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { LocationsModule } from '../locations/locations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { UsersModule } from '../users/users.module';
|
||||
FraudModule,
|
||||
WalletModule,
|
||||
UsersModule,
|
||||
LocationsModule,
|
||||
],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService, TripStateService],
|
||||
|
||||
@@ -19,6 +19,7 @@ import { WalletService } from '../wallet/wallet.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { TripState, TripStateService } from './trip-state.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { DriverLocationService } from '../locations/driver-location.service';
|
||||
|
||||
/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */
|
||||
const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
|
||||
@@ -71,6 +72,7 @@ export class TripsService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly state: TripStateService,
|
||||
private readonly users: UsersService,
|
||||
private readonly locations: DriverLocationService,
|
||||
) {}
|
||||
|
||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||
@@ -182,6 +184,9 @@ export class TripsService {
|
||||
}
|
||||
|
||||
const trip = await this.getOr404(tenantId, tripId);
|
||||
// مشغول الآن: يخرج من فهرس المتاحين فلا تُعرض عليه رحلة أخرى (H5).
|
||||
await this.locations.setAvailability(tenantId, driver.id, driver.service_class, 'busy');
|
||||
|
||||
await this.recordEvent(trip, 'searching', 'assigned', 'driver');
|
||||
const driverUser = await this.users.findById(tenantId, driverUserId);
|
||||
await this.notifyParties(tenantId, trip, 'assigned', driverUserId, {
|
||||
@@ -212,18 +217,16 @@ export class TripsService {
|
||||
|
||||
const patch: Partial<Trip> = { status: toStatus };
|
||||
|
||||
// كشف احتيال عند نقاط حسّاسة
|
||||
if (toStatus === 'driver_arrived' && snapshot.driver_id) {
|
||||
const drv = await this.drivers.findById(tenantId, snapshot.driver_id);
|
||||
if (drv) {
|
||||
await this.fraud.checkArrivedProximity(
|
||||
tenantId,
|
||||
drv.user_id,
|
||||
{ last_lat: drv.last_lat, last_lng: drv.last_lng },
|
||||
{ lat: snapshot.origin_lat, lng: snapshot.origin_lng },
|
||||
tripId,
|
||||
);
|
||||
}
|
||||
// كشف احتيال عند نقاط حسّاسة — الموقع الحيّ من Redis، لا صف السائق.
|
||||
if (toStatus === 'driver_arrived' && snapshot.driver_id && snapshot.driver_user_id) {
|
||||
const at = await this.locations.get(tenantId, snapshot.driver_id);
|
||||
await this.fraud.checkArrivedProximity(
|
||||
tenantId,
|
||||
snapshot.driver_user_id,
|
||||
at ? { lat: at.lat, lng: at.lng } : null,
|
||||
{ lat: snapshot.origin_lat, lng: snapshot.origin_lng },
|
||||
tripId,
|
||||
);
|
||||
}
|
||||
if (toStatus === 'completed') {
|
||||
patch.completed_at = new Date();
|
||||
@@ -249,7 +252,7 @@ export class TripsService {
|
||||
}
|
||||
|
||||
const trip = await this.getOr404(tenantId, tripId);
|
||||
await this.syncState(trip, snapshot.driver_user_id, toStatus);
|
||||
await this.syncState(trip, snapshot, toStatus);
|
||||
await this.recordEvent(trip, snapshot.status, toStatus, actor);
|
||||
|
||||
// تسوية المحفظة عند الدفع (payment_method === wallet)
|
||||
@@ -296,7 +299,7 @@ export class TripsService {
|
||||
if (!res.affected) throw new BadRequestException('Trip is no longer cancellable');
|
||||
|
||||
const trip = await this.getOr404(tenantId, tripId);
|
||||
await this.syncState(trip, snapshot.driver_user_id, 'cancelled');
|
||||
await this.syncState(trip, snapshot, 'cancelled');
|
||||
await this.recordEvent(trip, snapshot.status, 'cancelled', actor);
|
||||
|
||||
// الإلغاء وهي searching: العرض معلّق عند سائقين — أزِله.
|
||||
@@ -322,7 +325,9 @@ export class TripsService {
|
||||
if (driver.verification_status !== 'approved') {
|
||||
throw new ForbiddenException('Driver not approved');
|
||||
}
|
||||
if (driver.last_lat == null || driver.last_lng == null) return [];
|
||||
// موقعه الحيّ من Redis — لقطة القاعدة قد تتأخّر ثوانٍ عن الواقع.
|
||||
const at = await this.locations.get(tenantId, driver.id);
|
||||
if (!at) return [];
|
||||
|
||||
const open = await this.trips.find({
|
||||
where: {
|
||||
@@ -337,7 +342,7 @@ export class TripsService {
|
||||
return open
|
||||
.map((t) => ({
|
||||
trip: t,
|
||||
distanceKm: haversineKm(driver.last_lat!, driver.last_lng!, t.origin_lat, t.origin_lng),
|
||||
distanceKm: haversineKm(at.lat, at.lng, t.origin_lat, t.origin_lng),
|
||||
}))
|
||||
.filter((r) => r.distanceKm <= radiusKm)
|
||||
.sort((a, b) => a.distanceKm - b.distanceKm)
|
||||
@@ -390,11 +395,22 @@ export class TripsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncState(trip: Trip, driverUserId: string | null, to: TripStatus) {
|
||||
private async syncState(trip: Trip, snapshot: TripState, to: TripStatus) {
|
||||
if (TERMINAL.includes(to)) {
|
||||
await this.state.clear(trip.tenant_id, trip.id);
|
||||
} else {
|
||||
await this.state.save(trip, driverUserId);
|
||||
await this.state.save(trip, snapshot.driver_user_id);
|
||||
}
|
||||
|
||||
// انتهى دور السائق (أنهى أو أُلغيت) → يعود لفهرس المتاحين (docs/17 — H5).
|
||||
// 'completed' يحرّره وإن لم يُدفع بعد: القيادة انتهت فعلاً.
|
||||
if (snapshot.driver_id && (to === 'completed' || TERMINAL.includes(to))) {
|
||||
await this.locations.setAvailability(
|
||||
trip.tenant_id,
|
||||
snapshot.driver_id,
|
||||
snapshot.service_class,
|
||||
'available',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,20 +57,34 @@ export class RealtimeGateway implements OnGatewayConnection {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** موقع السائق الحي: يحدّث Redis GEO ويبثّ لغرفة الرحلة. */
|
||||
/**
|
||||
* موقع السائق الحي: Redis فقط (لا قاعدة — docs/17 H1)، والبثّ لغرفة الرحلة
|
||||
* **فقط عند حركة ذات دلالة**؛ سائق واقف يرسل كل ثانية لا يُغرق الراكب
|
||||
* بتحديثات متطابقة.
|
||||
*/
|
||||
@SubscribeMessage('driver:location')
|
||||
async onDriverLocation(
|
||||
@ConnectedSocket() c: Socket,
|
||||
@MessageBody() body: { lat: number; lng: number; tripId?: string },
|
||||
@MessageBody()
|
||||
body: { lat: number; lng: number; heading?: number; speed?: number; tripId?: string },
|
||||
) {
|
||||
const u = c.data.user;
|
||||
if (!u || u.role !== 'driver' || body?.lat == null || body?.lng == null) return;
|
||||
await this.drivers.updateLocation(u.tenantId, u.userId, body.lat, body.lng);
|
||||
if (body.tripId) {
|
||||
|
||||
const { significant } = await this.drivers.updateLocation(
|
||||
u.tenantId,
|
||||
u.userId,
|
||||
body.lat,
|
||||
body.lng,
|
||||
{ heading: body.heading, speed: body.speed },
|
||||
);
|
||||
if (body.tripId && significant) {
|
||||
c.to(this.tripRoom(u.tenantId, body.tripId)).emit('driver:location', {
|
||||
tripId: body.tripId,
|
||||
lat: body.lat,
|
||||
lng: body.lng,
|
||||
heading: body.heading ?? 0,
|
||||
speed: body.speed ?? 0,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import configuration from './config/configuration';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { LocationsModule } from './modules/locations/locations.module';
|
||||
|
||||
/**
|
||||
* وحدة الـworker — عملية منفصلة (`node dist/worker.js`) لا تخدم HTTP.
|
||||
* تحمل ما يحتاجه العمل الدوري فقط: قاعدة + Redis + المواقع.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) => ({
|
||||
type: 'postgres' as const,
|
||||
host: cfg.get<string>('db.host'),
|
||||
port: cfg.get<number>('db.port'),
|
||||
database: cfg.get<string>('db.name'),
|
||||
username: cfg.get<string>('db.user'),
|
||||
password: cfg.get<string>('db.password'),
|
||||
// بركة أصغر من الـAPI: الـworker يكتب دفعات قليلة لا طلبات متزامنة.
|
||||
extra: { max: parseInt(process.env.WORKER_DB_POOL_MAX ?? '5', 10) },
|
||||
entityPrefix: cfg.get<string>('db.tablePrefix'),
|
||||
synchronize: false,
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
RedisModule,
|
||||
LocationsModule,
|
||||
],
|
||||
})
|
||||
export class WorkerModule {}
|
||||
+47
-8
@@ -1,17 +1,56 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { WorkerModule } from './worker.module';
|
||||
import { LocationFlusherService } from './modules/locations/location-flusher.service';
|
||||
|
||||
/**
|
||||
* نقطة دخول الـ worker (BullMQ) — مهام غير متزامنة: انتهاء صلاحية العروض،
|
||||
* الإشعارات، التسويات، تجميع usage للفوترة (راجع docs/09).
|
||||
* حالياً هيكل فقط؛ المعالِجات تُضاف في P1.
|
||||
* نقطة دخول الـ worker — مهام غير متزامنة خارج مسار الطلب (docs/09، docs/17 H3/H4).
|
||||
* حالياً: تفريغ لقطات المواقع ونقاط المسار من Redis إلى القاعدة دورياً.
|
||||
*/
|
||||
const FLUSH_INTERVAL_MS = parseInt(process.env.LOC_FLUSH_INTERVAL_MS ?? '5000', 10);
|
||||
|
||||
async function bootstrap() {
|
||||
const log = new Logger('Worker');
|
||||
const prefix = process.env.QUEUE_PREFIX ?? 'tripz_';
|
||||
const redisDb = process.env.REDIS_DB ?? '3';
|
||||
log.log(`Tripz worker up. queuePrefix=${prefix} redisDb=${redisDb}`);
|
||||
// TODO(P1): سجّل معالِجات BullMQ هنا.
|
||||
setInterval(() => void 0, 1 << 30);
|
||||
const app = await NestFactory.createApplicationContext(WorkerModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
const flusher = app.get(LocationFlusherService);
|
||||
|
||||
log.log(
|
||||
`Tripz worker up. locationFlush=${FLUSH_INTERVAL_MS}ms ` +
|
||||
`queuePrefix=${process.env.QUEUE_PREFIX ?? 'tripz_'} redisDb=${process.env.REDIS_DB ?? '3'}`,
|
||||
);
|
||||
|
||||
// قفل بسيط: دورة لا تبدأ قبل أن تنتهي سابقتها — دفعة بطيئة يجب ألّا تتراكب
|
||||
// مع التالية فتُدرَج النقاط مرتين أو تُغرق القاعدة.
|
||||
let running = false;
|
||||
const timer = setInterval(async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
await flusher.flush();
|
||||
} catch (e: any) {
|
||||
log.error(`flush cycle failed: ${e?.message}`);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
log.log(`${signal} — draining…`);
|
||||
clearInterval(timer);
|
||||
// تفريغة أخيرة حتى لا تضيع اللقطات المعلّقة عند إعادة النشر.
|
||||
try {
|
||||
await flusher.flush();
|
||||
} catch (e: any) {
|
||||
log.warn(`final flush failed: ${e?.message}`);
|
||||
}
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
Reference in New Issue
Block a user