feat: driver documents (sovereignty storage adapter) + load-test script
- documents: upload (multipart) + review (admin/CS) + auto-approve driver when all docs approved - StorageService: per-tenant local volume now, in-country storage swap via STORAGE_BASE_URL - doc.number encrypted (AES-256-GCM); migration InitDocuments; tripz-storage volume - scripts/loadtest.mjs: concurrent full trip-cycle benchmark (throughput + latency percentiles) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,8 @@ services:
|
||||
depends_on: [postgres, redis]
|
||||
ports:
|
||||
- "${API_PORT:-4010}:4010"
|
||||
volumes:
|
||||
- tripz-storage:/app/storage
|
||||
networks: [tripz-net]
|
||||
|
||||
worker:
|
||||
@@ -68,6 +70,7 @@ services:
|
||||
volumes:
|
||||
tripz-pgdata:
|
||||
tripz-redisdata:
|
||||
tripz-storage:
|
||||
|
||||
networks:
|
||||
tripz-net:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// اختبار تحمّل: يشغّل دورة رحلة كاملة (طلب→قبول→حالات→دفع) بالتوازي ويقيس القدرة.
|
||||
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات. يغطّي: تعرفة + خرائط + مطابقة GEO
|
||||
// + آلة الحالة + كتابة القاعدة + بث السوكت (خادمياً).
|
||||
//
|
||||
// التشغيل (حاوية node مؤقتة على شبكة tripz):
|
||||
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||||
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/loadtest.mjs 300 30
|
||||
// الوسائط: <إجمالي الرحلات> <التزامن>
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||
const TENANT = process.env.TENANT || 'siro';
|
||||
const TOTAL = parseInt(process.argv[2] || '200', 10);
|
||||
const CONC = parseInt(process.argv[3] || '20', 10);
|
||||
const CODE = '1234';
|
||||
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
|
||||
|
||||
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
|
||||
const jitter = (n) => n + (Math.random() - 0.5) * 0.01;
|
||||
|
||||
async function api(method, path, token, body) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: { ...H(token), 'x-tenant-id': TENANT },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text().catch(() => '')}`);
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function token(phone) {
|
||||
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
|
||||
return r.access_token;
|
||||
}
|
||||
|
||||
async function setupDriver(i) {
|
||||
const t = await token(`0795${String(100000 + i).slice(-6)}`);
|
||||
const d = await api('POST', '/drivers/apply', t, { vehicle_make: 'LoadTest', service_class: 'economy' });
|
||||
await api('PATCH', `/drivers/${d.id}/approve`, t, {});
|
||||
await api('PATCH', '/drivers/status', t, { online: true });
|
||||
await api('POST', '/drivers/location', t, { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) });
|
||||
return t;
|
||||
}
|
||||
|
||||
async function oneTrip(riderTok, driverTok) {
|
||||
const start = Date.now();
|
||||
const { trip } = await api('POST', '/trips', riderTok, {
|
||||
origin: { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) },
|
||||
destination: { lat: jitter(ORIGIN.lat + 0.03), lng: jitter(ORIGIN.lng + 0.03) },
|
||||
service_class: 'economy',
|
||||
});
|
||||
await api('POST', `/trips/${trip.id}/accept`, driverTok, {});
|
||||
for (const s of ['driver_arriving', 'driver_arrived', 'in_progress', 'completed', 'paid']) {
|
||||
await api('PATCH', `/trips/${trip.id}/status`, driverTok, { status: s });
|
||||
}
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Setup ${CONC} drivers+riders...`);
|
||||
const drivers = [], riders = [];
|
||||
for (let i = 0; i < CONC; i++) {
|
||||
drivers.push(await setupDriver(i));
|
||||
riders.push(await token(`0796${String(100000 + i).slice(-6)}`));
|
||||
}
|
||||
|
||||
console.log(`Running ${TOTAL} trips @ concurrency ${CONC}...`);
|
||||
let done = 0, errors = 0;
|
||||
const lats = [];
|
||||
const t0 = Date.now();
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: CONC }, (_, w) =>
|
||||
(async () => {
|
||||
while (true) {
|
||||
const idx = done++;
|
||||
if (idx >= TOTAL) break;
|
||||
try {
|
||||
lats.push(await oneTrip(riders[w], drivers[w]));
|
||||
} catch (e) {
|
||||
errors++;
|
||||
if (errors <= 5) console.error('ERR:', e.message);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
),
|
||||
);
|
||||
|
||||
const secs = (Date.now() - t0) / 1000;
|
||||
lats.sort((a, b) => a - b);
|
||||
const pct = (p) => lats[Math.min(lats.length - 1, Math.floor((p / 100) * lats.length))] || 0;
|
||||
const ok = lats.length;
|
||||
console.log('\n===== النتائج =====');
|
||||
console.log(`رحلات ناجحة: ${ok} / ${TOTAL} · أخطاء: ${errors}`);
|
||||
console.log(`الزمن: ${secs.toFixed(1)}s · الإنتاجية: ${(ok / secs).toFixed(1)} رحلة/ث (~${Math.round((ok / secs) * 86400).toLocaleString()} رحلة/يوم)`);
|
||||
console.log(`زمن الرحلة الكاملة (ms): p50=${pct(50)} · p95=${pct(95)} · max=${pct(100)}`);
|
||||
console.log('ملاحظة: كل رحلة = 7 نداءات API (طلب+قبول+5 حالات).');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -25,6 +25,8 @@ import { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||
import { WalletModule } from './modules/wallet/wallet.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { DocumentsModule } from './modules/documents/documents.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -51,6 +53,7 @@ import { PaymentsModule } from './modules/payments/payments.module';
|
||||
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
|
||||
NabehModule, // عالمي — إرسال OTP واتساب
|
||||
NotificationsModule, // عالمي — FCM
|
||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||
|
||||
HealthModule,
|
||||
TenantsModule,
|
||||
@@ -69,6 +72,7 @@ import { PaymentsModule } from './modules/payments/payments.module';
|
||||
DispatchModule,
|
||||
ChatModule,
|
||||
RatingsModule,
|
||||
DocumentsModule,
|
||||
SeedModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, dirname, extname } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* مُحوّل تخزين الملفات. محلياً الآن (قرص/فوليوم)، وقابل للتحويل لسيرفر تخزين
|
||||
* داخل البلد (السيادة الرقمية) بتغيير STORAGE_BASE_URL + STORAGE_DIR (docs/16، docs/06).
|
||||
* الملفات لكل مستأجر معزولة تحت مجلده.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get dir(): string {
|
||||
return this.config.get<string>('storage.dir') ?? '/app/storage';
|
||||
}
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('storage.baseUrl') ?? '';
|
||||
}
|
||||
|
||||
/** يحفظ ملفاً ويعيد مفتاحه (مسار نسبي معزول بالمستأجر). */
|
||||
async save(
|
||||
tenantId: string,
|
||||
scope: string,
|
||||
buffer: Buffer,
|
||||
originalName = '',
|
||||
): Promise<string> {
|
||||
const ext = extname(originalName) || '.bin';
|
||||
const key = `${tenantId}/${scope}/${randomUUID()}${ext}`;
|
||||
const full = join(this.dir, key);
|
||||
await fs.mkdir(dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, buffer);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** رابط الوصول (يشير لسيرفر التوطين حين يُضبط STORAGE_BASE_URL). */
|
||||
url(key: string): string {
|
||||
return this.baseUrl ? `${this.baseUrl}/${key}` : `/storage/${key}`;
|
||||
}
|
||||
|
||||
async read(key: string): Promise<Buffer> {
|
||||
return fs.readFile(join(this.dir, key));
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,12 @@ export default () => ({
|
||||
// رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال).
|
||||
callingCodes: { jo: '962', sy: '963' } as Record<string, string>,
|
||||
|
||||
// تخزين ملفات الوثائق. baseUrl فارغ = محلي؛ يُضبط لسيرفر التوطين لاحقاً (docs/06).
|
||||
storage: {
|
||||
dir: process.env.STORAGE_DIR ?? '/app/storage',
|
||||
baseUrl: process.env.STORAGE_BASE_URL ?? '',
|
||||
},
|
||||
|
||||
// إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط).
|
||||
fcm: {
|
||||
serverKey: process.env.FCM_SERVER_KEY ?? '',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** وثائق السائق. الجدول: tripz_driver_documents. */
|
||||
export class InitDocuments1721700000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_driver_documents (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
driver_id uuid NOT NULL,
|
||||
type varchar NOT NULL,
|
||||
file_key varchar NOT NULL,
|
||||
number varchar,
|
||||
expiry_date date,
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
review_note varchar,
|
||||
reviewed_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_driver" ON tripz_driver_documents (tenant_id, driver_id)`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_status" ON tripz_driver_documents (tenant_id, status)`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_driver_documents`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { DocumentsService } from './documents.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('documents')
|
||||
@ApiBearerAuth()
|
||||
@Controller('drivers/documents')
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
// السائق يرفع وثيقة (multipart: file + type + number + expiry_date)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
upload(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@UploadedFile() file: any,
|
||||
@Body() body: any,
|
||||
) {
|
||||
return this.documents.upload(user.tenantId, user.userId, file, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('mine')
|
||||
mine(@CurrentUser() user: AuthUser) {
|
||||
return this.documents.listMine(user.tenantId, user.userId);
|
||||
}
|
||||
|
||||
// مراجعة: خدمة العملاء/الأدمن
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Get('pending')
|
||||
pending(@CurrentUser() user: AuthUser) {
|
||||
return this.documents.pending(user.tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Patch(':id/review')
|
||||
review(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body('status') status: 'approved' | 'rejected',
|
||||
@Body('note') note: string,
|
||||
) {
|
||||
return this.documents.review(user.tenantId, id, user.userId, status, note);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DriverDocument } from './entities/driver-document.entity';
|
||||
import { DocumentsService } from './documents.service';
|
||||
import { DocumentsController } from './documents.controller';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DriverDocument]), DriversModule],
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService],
|
||||
exports: [DocumentsService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DriverDocument, DocStatus, DocType } from './entities/driver-document.entity';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
constructor(
|
||||
@InjectRepository(DriverDocument) private readonly repo: Repository<DriverDocument>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly drivers: DriversService,
|
||||
) {}
|
||||
|
||||
async upload(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
file: { buffer: Buffer; originalname?: string },
|
||||
body: { type: DocType; number?: string; expiry_date?: string },
|
||||
) {
|
||||
const driver = await this.drivers.findByUser(tenantId, userId);
|
||||
if (!driver) throw new ForbiddenException('driver profile required');
|
||||
if (!file?.buffer) throw new NotFoundException('file is required');
|
||||
|
||||
const key = await this.storage.save(tenantId, `docs/${driver.id}`, file.buffer, file.originalname);
|
||||
const doc = await this.repo.save(
|
||||
this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
driver_id: driver.id,
|
||||
type: body.type,
|
||||
file_key: key,
|
||||
number: body.number ?? null,
|
||||
expiry_date: body.expiry_date ?? null,
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
return this.withUrl(doc);
|
||||
}
|
||||
|
||||
async listMine(tenantId: string, userId: string) {
|
||||
const driver = await this.drivers.findByUser(tenantId, userId);
|
||||
if (!driver) return [];
|
||||
const docs = await this.repo.find({
|
||||
where: { tenant_id: tenantId, driver_id: driver.id },
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
return docs.map((d) => this.withUrl(d));
|
||||
}
|
||||
|
||||
async pending(tenantId: string) {
|
||||
const docs = await this.repo.find({
|
||||
where: { tenant_id: tenantId, status: 'pending' },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
return docs.map((d) => this.withUrl(d));
|
||||
}
|
||||
|
||||
/** مراجعة خدمة العملاء/الأدمن. عند اعتماد كل الوثائق يُعتمد السائق. */
|
||||
async review(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
reviewerId: string,
|
||||
status: DocStatus,
|
||||
note?: string,
|
||||
) {
|
||||
const doc = await this.repo.findOne({ where: { tenant_id: tenantId, id } });
|
||||
if (!doc) throw new NotFoundException('document not found');
|
||||
doc.status = status;
|
||||
doc.review_note = note ?? null;
|
||||
doc.reviewed_by = reviewerId;
|
||||
await this.repo.save(doc);
|
||||
|
||||
if (status === 'approved') {
|
||||
const remaining = await this.repo.count({
|
||||
where: { tenant_id: tenantId, driver_id: doc.driver_id, status: 'pending' },
|
||||
});
|
||||
const rejected = await this.repo.count({
|
||||
where: { tenant_id: tenantId, driver_id: doc.driver_id, status: 'rejected' },
|
||||
});
|
||||
if (remaining === 0 && rejected === 0) {
|
||||
await this.drivers.approve(tenantId, doc.driver_id);
|
||||
}
|
||||
}
|
||||
return this.withUrl(doc);
|
||||
}
|
||||
|
||||
private withUrl(d: DriverDocument) {
|
||||
return { ...d, url: this.storage.url(d.file_key) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||
|
||||
export type DocType =
|
||||
| 'license'
|
||||
| 'national_id'
|
||||
| 'vehicle_registration'
|
||||
| 'insurance'
|
||||
| 'selfie'
|
||||
| 'vehicle_photo';
|
||||
export type DocStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
/** وثيقة سائق (صورة + بيانات). الجدول: tripz_driver_documents. */
|
||||
@Entity('driver_documents')
|
||||
@Index(['tenant_id', 'driver_id'])
|
||||
@Index(['tenant_id', 'status'])
|
||||
export class DriverDocument {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
driver_id: string;
|
||||
|
||||
@Column()
|
||||
type: DocType;
|
||||
|
||||
@Column()
|
||||
file_key: string; // مفتاح التخزين (سيرفر التوطين لاحقاً)
|
||||
|
||||
// رقم الرخصة/الهوية — مشفّر at-rest (AES-256-GCM)
|
||||
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
||||
number: string | null;
|
||||
|
||||
@Column({ type: 'date', nullable: true })
|
||||
expiry_date: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status: DocStatus;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
review_note: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
reviewed_by: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
Reference in New Issue
Block a user