chore: أرشفة باك إند NestJS إلى backend-archive
قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com. backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى) + README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات). مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
75b80bd299
commit
fff76c949f
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { DriversService } from '../modules/drivers/drivers.service';
|
||||
import { Trip } from '../modules/trips/entities/trip.entity';
|
||||
|
||||
/**
|
||||
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
|
||||
* بث مواقع السائقين لحظياً، وإشارات مكالمات WebRTC (offer/answer/ice) عبر غرفة الرحلة.
|
||||
*/
|
||||
@WebSocketGateway({ cors: true })
|
||||
export class RealtimeGateway implements OnGatewayConnection {
|
||||
private readonly logger = new Logger('Realtime');
|
||||
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly drivers: DriversService,
|
||||
@InjectRepository(Trip) private readonly trips: Repository<Trip>,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
try {
|
||||
const token =
|
||||
(client.handshake.auth?.token as string) ||
|
||||
(client.handshake.headers?.authorization as string)?.replace('Bearer ', '');
|
||||
if (!token) throw new Error('no token');
|
||||
const p: any = this.jwt.verify(token);
|
||||
client.data.user = { userId: p.sub, tenantId: p.tenant_id, role: p.role };
|
||||
client.join(`tenant:${p.tenant_id}:user:${p.sub}`);
|
||||
if (p.role === 'driver') client.join(`tenant:${p.tenant_id}:drivers`);
|
||||
} catch {
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
private tripRoom(tenantId: string, tripId: string) {
|
||||
return `tenant:${tenantId}:trip:${tripId}`;
|
||||
}
|
||||
|
||||
// ---- اشتراكات الويب سوكت ----
|
||||
|
||||
/** الطرفان ينضمّان لغرفة الرحلة لاستقبال التتبع والدردشة والمكالمات. */
|
||||
@SubscribeMessage('trip:join')
|
||||
onTripJoin(@ConnectedSocket() c: Socket, @MessageBody() body: { tripId: string }) {
|
||||
const u = c.data.user;
|
||||
if (!u || !body?.tripId) return;
|
||||
c.join(this.tripRoom(u.tenantId, body.tripId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* موقع السائق الحي: Redis فقط (لا قاعدة — docs/17 H1)، والبثّ لغرفة الرحلة
|
||||
* **فقط عند حركة ذات دلالة**؛ سائق واقف يرسل كل ثانية لا يُغرق الراكب
|
||||
* بتحديثات متطابقة.
|
||||
*/
|
||||
@SubscribeMessage('driver:location')
|
||||
async onDriverLocation(
|
||||
@ConnectedSocket() c: Socket,
|
||||
@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;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
|
||||
/**
|
||||
* `offer` هو حدث **بدء الاتصال** فعلياً (answer/ice/end إشارات تفاوض
|
||||
* لاحقة على نفس المكالمة) — لذا هنا فقط نُسجّل إشارة الاحتيال (docs/17 —
|
||||
* E1). الكتابة على القاعدة مباشرة لا عبر Redis: تكرار المكالمات لا يقارن
|
||||
* بتكرار نبضات الموقع، فلا حاجة لتجميع (نمط H) هنا.
|
||||
*/
|
||||
@SubscribeMessage('call:offer')
|
||||
async onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||
this.relay(c, 'call:offer', b);
|
||||
const u = c.data.user;
|
||||
if (!u || !b?.tripId) return;
|
||||
try {
|
||||
await this.trips
|
||||
.createQueryBuilder()
|
||||
.update(Trip)
|
||||
.set({
|
||||
last_call_at: () => 'now()',
|
||||
last_call_by: u.role,
|
||||
call_count: () => 'call_count + 1',
|
||||
...(u.role === 'driver' ? { driver_called_passenger: true } : {}),
|
||||
})
|
||||
.where('tenant_id = :tenantId AND id = :tripId', { tenantId: u.tenantId, tripId: b.tripId })
|
||||
.execute();
|
||||
} catch (e: any) {
|
||||
// تسجيل الإشارة لا يجوز أن يقطع المكالمة نفسها — أُعيد المحاولة لاحقاً
|
||||
// غير ممكنة هنا، فنكتفي باللوغ.
|
||||
this.logger.warn(`call signal record failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('call:answer')
|
||||
onCallAnswer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||
this.relay(c, 'call:answer', b);
|
||||
}
|
||||
|
||||
@SubscribeMessage('call:ice')
|
||||
onCallIce(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||
this.relay(c, 'call:ice', b);
|
||||
}
|
||||
|
||||
@SubscribeMessage('call:end')
|
||||
onCallEnd(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||
this.relay(c, 'call:end', b);
|
||||
}
|
||||
|
||||
private relay(c: Socket, event: string, b: any) {
|
||||
const u = c.data.user;
|
||||
if (!u || !b?.tripId) return;
|
||||
c.to(this.tripRoom(u.tenantId, b.tripId)).emit(event, { from: u.userId, ...b });
|
||||
}
|
||||
|
||||
// ---- مساعدات البث التي تستدعيها الخدمات ----
|
||||
offerToDriver(tenantId: string, driverUserId: string, payload: any) {
|
||||
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload);
|
||||
}
|
||||
|
||||
/** العرض مات (قبِله غيره أو أُلغي) — يزيله من شاشة السائق والـoverlay. */
|
||||
offerTaken(tenantId: string, driverUserId: string, payload: any) {
|
||||
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer_taken', payload);
|
||||
}
|
||||
|
||||
tripUpdate(tenantId: string, userId: string, payload: any) {
|
||||
this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload);
|
||||
}
|
||||
|
||||
dispatch(tenantId: string, event: string, payload: any) {
|
||||
this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RealtimeGateway } from './realtime.gateway';
|
||||
import { DriversModule } from '../modules/drivers/drivers.module';
|
||||
import { Trip } from '../modules/trips/entities/trip.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DriversModule,
|
||||
// تسجيل الكيان مباشرة لا وحدة TripsModule كاملة — TripsModule تستورد
|
||||
// RealtimeModule أصلاً، فاستيراد معاكس يصنع دورة (docs/17 — E1).
|
||||
TypeOrmModule.forFeature([Trip]),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (cfg: ConfigService) => ({
|
||||
secret: cfg.get<string>('JWT_SECRET') || 'change_me_jwt_secret',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [RealtimeGateway],
|
||||
exports: [RealtimeGateway],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { INestApplicationContext, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { ServerOptions } from 'socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* مُحوّل Socket.IO مدعوم بـ Redis — يجعل التطبيق قابلاً للتشغيل بعدة نسخ
|
||||
* (توسّع أفقي) تتشارك اتصالات الويب سوكت عبر Redis. بلا هذا، رسالة من نسخة
|
||||
* لا تصل زبوناً متصلاً بنسخة أخرى (راجع docs/09).
|
||||
* مفتاح القنوات ببادئة tripz للعزل عن أي تطبيق آخر على نفس Redis.
|
||||
*/
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
private readonly logger = new Logger('SocketRedis');
|
||||
private adapterConstructor: ReturnType<typeof createAdapter>;
|
||||
|
||||
constructor(
|
||||
app: INestApplicationContext,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
async connectToRedis(): Promise<void> {
|
||||
const opts = {
|
||||
host: this.config.get<string>('redis.host'),
|
||||
port: this.config.get<number>('redis.port'),
|
||||
db: this.config.get<number>('redis.db'),
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
const pub = new Redis(opts);
|
||||
const sub = pub.duplicate();
|
||||
pub.on('error', (e) => this.logger.warn(`pub: ${e.message}`));
|
||||
sub.on('error', (e) => this.logger.warn(`sub: ${e.message}`));
|
||||
// key يعزل قنوات socket.io الخاصة بـ Tripz عن غيرها على نفس Redis
|
||||
this.adapterConstructor = createAdapter(pub, sub, { key: 'tripz:sio' });
|
||||
this.logger.log('Socket.IO Redis adapter ready (horizontal scale enabled)');
|
||||
}
|
||||
|
||||
createIOServer(port: number, options?: ServerOptions): any {
|
||||
const server = super.createIOServer(port, options);
|
||||
if (this.adapterConstructor) server.adapter(this.adapterConstructor);
|
||||
return server;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user