feat: المجموعة E (كشف الاحتيال) + تشفير الهاتف at-rest
E — اتصال ثم إلغاء (من driver_ride_scam عند سيرو): - E1: driver_called_passenger + last_call_at/by/count على الرحلة نفسها لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة وزوالها من الكاش. يُسجَّل عند call:offer (بدء المكالمة الفعلي) في RealtimeGateway عبر Repository<Trip> مباشرة — لا استيراد TripsModule كاملاً (يستورد RealtimeModule أصلاً، فدورة استيراد) - E2: FraudService.recordCallThenCancel — عدّاد يومي منفصل عن عدّاد الإلغاء العام بالساعة. 3/يوم = إنذار لا يمنع الإلغاء، 6/يوم = حظر مؤقت (طلب المالك حرفياً). يرتبط فقط إن كانت المكالمة خلال 30 دقيقة من الإلغاء تشفير الهاتف at-rest (ملاحظة المالك) — بفهرس أعمى منفصل، لا تشفير ساذج: - AES-256-GCM عشوائي IV يمنع WHERE phone = :x من المطابقة أصلاً (كل تشفير مختلف شكلاً حتى لنفس الرقم) — لو طُبِّق التشفير مباشرة بلا هذا لكسر تسجيل الدخول بالكامل بصمت - users.phone_bidx = HMAC-SHA256(الرقم المطبَّع) — حتمي، يصلح للبحث وللقيد الفريد. القيد انتقل من (tenant_id, phone) إلى (tenant_id, phone_bidx) — القديم كان سيقبل تكرار نفس الرقم الحقيقي فعلياً - findByPhone/create يحسبان البصمة؛ لا نقطة أخرى تقرأ phone بمساواة خام - هجرة EncryptPhone تُشفّر الصفوف القائمة فوراً وتبني البصمة بأثر رجعي — استثناء متعمَّد من تسامح decrypt() المعتاد (الهاتف عمود بحث يومي، خطره كنص صافٍ أعلى من name) - يعتمد على تطبيع D1: بصمة حتمية لنفس *النص*، فلا بد أن يصل الرقم مطبَّعاً قبلها وإلا عاد نفس الرقم بصيغتين ليصير رقمين مرة أخرى هجرات: TripCallSignal · EncryptPhone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb79fee99
commit
253fbb4b2a
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger
|
||||
* عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها
|
||||
* حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش.
|
||||
*/
|
||||
export class TripCallSignal1721890000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`);
|
||||
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`);
|
||||
await q.query(
|
||||
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
|
||||
|
||||
/**
|
||||
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
|
||||
*
|
||||
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
|
||||
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
|
||||
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
|
||||
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
|
||||
* نفسها لنفس الرقم دائماً.
|
||||
*
|
||||
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
|
||||
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
|
||||
*/
|
||||
export class EncryptPhone1721900000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
|
||||
|
||||
const rows: { id: string; phone: string }[] = await q.query(
|
||||
`SELECT id, phone FROM tripz_users`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
|
||||
const alreadyEncrypted = row.phone?.startsWith('v1:');
|
||||
const canonicalPhone = alreadyEncrypted ? null : row.phone;
|
||||
const bidx = blindIndex(canonicalPhone ?? row.phone);
|
||||
|
||||
if (alreadyEncrypted) {
|
||||
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
|
||||
} else {
|
||||
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
|
||||
encrypt(row.phone),
|
||||
bidx,
|
||||
row.id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
|
||||
ON tripz_users (tenant_id, phone_bidx)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
|
||||
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
|
||||
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
|
||||
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
|
||||
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ export class FraudService {
|
||||
private readonly ARRIVED_MAX_M = 350; // أقصى بعد مقبول عند "وصل"
|
||||
private readonly FAST_MIN_SEC = 30; // إنهاء أسرع من هذا يُشتبه به
|
||||
|
||||
// اتصال ثم إلغاء (docs/17 — E2؛ من driver_ride_scam عند سيرو).
|
||||
// نافذة يومية لا ساعة — مقصودة ومنفصلة عن CANCEL_SOFT/HARD: تلك تعدّ كل
|
||||
// إلغاء أياً كان سببه، وهذه تعدّ فقط الإلغاء الذي **سبقته مكالمة** — إشارة
|
||||
// اتفاق خارج التطبيق، لا مجرد كثرة إلغاء.
|
||||
private readonly CALL_CANCEL_WINDOW_MIN = 30; // اتصال خلال آخر نصف ساعة قبل الإلغاء يُحتسب مرتبطاً
|
||||
private readonly CALL_CANCEL_SOFT = 3; // إنذار — طلب المالك حرفياً: «3 إلغاءات/يوم»
|
||||
private readonly CALL_CANCEL_HARD = 6; // التكرار → إجراء (حظر مؤقت)
|
||||
|
||||
constructor(
|
||||
@InjectRepository(FraudFlag) private readonly repo: Repository<FraudFlag>,
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
@@ -70,6 +78,41 @@ export class FraudService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ربط الاتصال بالإلغاء (docs/17 — E2): اتصال ثم إلغاء سريع = مؤشر اتفاق
|
||||
* خارج التطبيق (الطرفان اتفقا هاتفياً وألغيا ليتجنّبا العمولة). يُنادى
|
||||
* من `TripsService.cancel` مع آخر وقت اتصال على الرحلة (أو null إن لم
|
||||
* تحدث مكالمة، فيخرج فوراً بلا أثر).
|
||||
*
|
||||
* عدّاد **يومي منفصل** عن `recordCancellation` عمداً: التكرار هنا سببه
|
||||
* الاتصال تحديداً لا كثرة الإلغاء العامة.
|
||||
*/
|
||||
async recordCallThenCancel(
|
||||
tenantId: string,
|
||||
subjectType: 'rider' | 'driver',
|
||||
userId: string,
|
||||
tripId: string,
|
||||
lastCallAt: Date | null,
|
||||
): Promise<void> {
|
||||
if (!lastCallAt) return;
|
||||
const minutesSinceCall = (Date.now() - lastCallAt.getTime()) / 60000;
|
||||
if (minutesSinceCall > this.CALL_CANCEL_WINDOW_MIN) return; // اتصال قديم — غير مرتبط
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const key = `call_cancel:${tenantId}:${userId}:${today}`;
|
||||
const count = await this.redis.incr(key);
|
||||
if (count === 1) await this.redis.expire(key, 26 * 3600); // يتجاوز منتصف الليل بأمان
|
||||
|
||||
if (count >= this.CALL_CANCEL_HARD) {
|
||||
await this.flag(tenantId, subjectType, userId, 'call_then_cancel_hard', { count }, tripId);
|
||||
throw new ForbiddenException('Too many call-then-cancel patterns today — temporarily blocked');
|
||||
}
|
||||
if (count >= this.CALL_CANCEL_SOFT) {
|
||||
// إنذار فقط — لا يمنع الإلغاء نفسه (طلب المالك: 3 = إنذار لا حظر).
|
||||
await this.flag(tenantId, subjectType, userId, 'call_then_cancel_soft', { count }, tripId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً.
|
||||
* `driverAt` هو الموقع **الحيّ** من Redis — لا لقطة القاعدة الدورية، وإلا
|
||||
|
||||
@@ -121,6 +121,22 @@ export class Trip {
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
cancel_fee: number | null;
|
||||
|
||||
// ---- إشارة المكالمة (docs/17 — E1؛ مكافئ isDriverCallPassenger عند سيرو) ----
|
||||
// اتصال ثم إلغاء سريع = مؤشر اتفاق خارج التطبيق (E2). نُخزَّن دائماً
|
||||
// (لا نكتفي بـRedis) لأنها حقيقة دائمة عن الرحلة تفيد التحقيق لاحقاً حتى
|
||||
// بعد انتهاء الرحلة وزوال حالتها من Redis.
|
||||
@Column({ default: false })
|
||||
driver_called_passenger: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_call_at: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
last_call_by: string | null; // rider | driver
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
call_count: number;
|
||||
|
||||
// ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ----
|
||||
@CreateDateColumn()
|
||||
requested_at: Date;
|
||||
|
||||
@@ -354,6 +354,21 @@ export class TripsService {
|
||||
// كشف إساءة الإلغاء (قد يرمي عند الحد الصارم)
|
||||
await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId);
|
||||
|
||||
// اتصال ثم إلغاء = مؤشر اتفاق خارج التطبيق (docs/17 — E2). last_call_at
|
||||
// ليست في حالة Redis (مكالمة نادرة الحدوث، لا تستحق كاش) — قراءة خفيفة
|
||||
// من القاعدة بعمود واحد.
|
||||
const callRow = await this.trips.findOne({
|
||||
where: { tenant_id: tenantId, id: tripId },
|
||||
select: { last_call_at: true },
|
||||
});
|
||||
await this.fraud.recordCallThenCancel(
|
||||
tenantId,
|
||||
actor,
|
||||
actorUserId,
|
||||
tripId,
|
||||
callRow?.last_call_at ?? null,
|
||||
);
|
||||
|
||||
const fee = await this.cancelFee(tenantId, tripId, snapshot);
|
||||
const res = await this.trips.update(
|
||||
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||
|
||||
export enum UserRole {
|
||||
@@ -8,7 +8,10 @@ export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
// القيد الحقيقي بعد التشفير (docs/17 — D1 إضافة): `phone` عشوائي التشفير
|
||||
// فلا يصلح فهرساً فريداً؛ `phone_bidx` الحتمي هو من يمنع الازدواج فعلياً.
|
||||
@Entity('users')
|
||||
@Index(['tenant_id', 'phone_bidx'], { unique: true })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
@@ -16,9 +19,21 @@ export class User {
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column()
|
||||
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة.
|
||||
// **لا يُستعمل في WHERE مباشرة**: IV عشوائي لكل عملية تشفير يعني أن نفس
|
||||
// الرقم ينتج شيفرة مختلفة كل مرة، فأي بحث بالمساواة على هذا العمود يفشل
|
||||
// دائماً. البحث والتفرّد كلاهما عبر `phone_bidx` (docs/16 §"blindIndex").
|
||||
@Column({ transformer: EncryptedTransformer })
|
||||
phone: string;
|
||||
|
||||
/**
|
||||
* فهرس أعمى حتمي (HMAC-SHA256) — نفس الرقم الحقيقي ينتج نفس القيمة دائماً،
|
||||
* فيصلح للبحث `WHERE` وللقيد الفريد `(tenant_id, phone_bidx)`. لا يكشف
|
||||
* الرقم (اتجاه واحد)، وهو المفتاح الفعلي لا `phone` (docs/17 — D1 إضافة).
|
||||
*/
|
||||
@Column()
|
||||
phone_bidx: string;
|
||||
|
||||
// مشفّر at-rest (AES-256-GCM) — يُفكّ تلقائياً عند القراءة
|
||||
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { newDb } from 'pg-mem';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { blindIndex, decrypt } from '../../common/crypto/crypto.util';
|
||||
|
||||
const TENANT_A = '11111111-1111-1111-1111-111111111111';
|
||||
const TENANT_B = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
function fakeCache() {
|
||||
return { wrap: jest.fn(async (_k: string, _t: number, loader: () => any) => loader()), set: jest.fn() } as any;
|
||||
}
|
||||
|
||||
describe('UsersService — الهاتف مشفَّر at-rest (docs/16، docs/17 D1)', () => {
|
||||
let ds: DataSource;
|
||||
let users: UsersService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = newDb({ autoCreateForeignKeyIndices: true });
|
||||
db.public.registerFunction({ name: 'version', returns: 'text' as any, implementation: () => 'pg-mem' });
|
||||
db.public.registerFunction({
|
||||
name: 'current_database',
|
||||
returns: 'text' as any,
|
||||
implementation: () => 'tripz',
|
||||
});
|
||||
db.registerExtension('uuid-ossp', (schema) =>
|
||||
schema.registerFunction({
|
||||
name: 'uuid_generate_v4',
|
||||
returns: 'uuid' as any,
|
||||
implementation: () => randomUUID(),
|
||||
impure: true,
|
||||
}),
|
||||
);
|
||||
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
|
||||
|
||||
ds = (await db.adapters.createTypeormDataSource({
|
||||
type: 'postgres',
|
||||
entities: [User],
|
||||
entityPrefix: 'tripz_',
|
||||
})) as DataSource;
|
||||
await ds.initialize();
|
||||
await ds.synchronize();
|
||||
|
||||
users = new UsersService(ds.getRepository(User), fakeCache());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (ds?.isInitialized) await ds.destroy();
|
||||
});
|
||||
|
||||
it('يقرأ رقماً مفكوك التشفير رغم أنه مخزَّن مشفّراً', async () => {
|
||||
const created = await users.create(TENANT_A, '962790000000');
|
||||
expect(created.phone).toBe('962790000000'); // الكائن المُعاد فوراً واضح
|
||||
|
||||
const fetched = await users.findById(TENANT_A, created.id);
|
||||
expect(fetched!.phone).toBe('962790000000'); // وكذلك بعد إعادة القراءة
|
||||
});
|
||||
|
||||
it('العمود على القاعدة مشفّر فعلاً — ليس نصّاً صافياً', async () => {
|
||||
const created = await users.create(TENANT_A, '962790000000');
|
||||
|
||||
const raw = await ds.query(`SELECT phone FROM tripz_users WHERE id = $1`, [created.id]);
|
||||
expect(raw[0].phone).not.toBe('962790000000');
|
||||
expect(raw[0].phone).toMatch(/^v1:/); // صيغة common/crypto/crypto.util
|
||||
expect(decrypt(raw[0].phone)).toBe('962790000000'); // لكنه قابل للفكّ
|
||||
});
|
||||
|
||||
it('نفس الرقم يُعطي نفس الفهرس الأعمى دائماً — والبحث يعمل رغم التشفير العشوائي', async () => {
|
||||
const created = await users.create(TENANT_A, '962790000000');
|
||||
|
||||
const found = await users.findByPhone(TENANT_A, '962790000000');
|
||||
expect(found?.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('الفهرس الأعمى نفسه لا يكشف الرقم — بصمة أحادية الاتجاه', async () => {
|
||||
const created = await users.create(TENANT_A, '962790000000');
|
||||
const raw = await ds.query(`SELECT phone_bidx FROM tripz_users WHERE id = $1`, [created.id]);
|
||||
|
||||
expect(raw[0].phone_bidx).toBe(blindIndex('962790000000'));
|
||||
expect(raw[0].phone_bidx).not.toContain('790000000');
|
||||
});
|
||||
|
||||
it('رقم مختلف لا يتصادف مع فهرس رقم آخر', async () => {
|
||||
await users.create(TENANT_A, '962790000000');
|
||||
expect(await users.findByPhone(TENANT_A, '962790000001')).toBeNull();
|
||||
});
|
||||
|
||||
it('القيد الفريد يحمي من ازدواج نفس الرقم داخل المستأجر الواحد', async () => {
|
||||
await users.create(TENANT_A, '962790000000');
|
||||
await expect(users.create(TENANT_A, '962790000000')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('نفس الرقم تحت مستأجرين مختلفين = مستخدمان منفصلان (عزل)', async () => {
|
||||
const a = await users.create(TENANT_A, '962790000000');
|
||||
const b = await users.create(TENANT_B, '962790000000');
|
||||
expect(a.id).not.toBe(b.id);
|
||||
|
||||
expect((await users.findByPhone(TENANT_A, '962790000000'))!.id).toBe(a.id);
|
||||
expect((await users.findByPhone(TENANT_B, '962790000000'))!.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('findOrCreate لا يُنشئ نسخة ثانية لنفس الرقم', async () => {
|
||||
const first = await users.findOrCreate(TENANT_A, '962790000000');
|
||||
const second = await users.findOrCreate(TENANT_A, '962790000000');
|
||||
expect(first.id).toBe(second.id);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User, UserRole } from './entities/user.entity';
|
||||
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||
import { blindIndex } from '../../common/crypto/crypto.util';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -12,8 +13,14 @@ export class UsersService {
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `phone` مشفّر عشوائي at-rest — `WHERE phone = :x` لا يطابق أبداً حتى مع
|
||||
* نفس الرقم (IV مختلف كل مرة). البحث دائماً عبر `phone_bidx` الحتمي.
|
||||
*/
|
||||
async findByPhone(tenantId: string, phone: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { tenant_id: tenantId, phone } });
|
||||
return this.userRepository.findOne({
|
||||
where: { tenant_id: tenantId, phone_bidx: blindIndex(phone) },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<User | null> {
|
||||
@@ -21,7 +28,12 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async create(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
|
||||
const user = this.userRepository.create({ tenant_id: tenantId, phone, role: role as UserRole });
|
||||
const user = this.userRepository.create({
|
||||
tenant_id: tenantId,
|
||||
phone,
|
||||
phone_bidx: blindIndex(phone),
|
||||
role: role as UserRole,
|
||||
});
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
} 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 + غرف مُنطَّقة بالمستأجر،
|
||||
@@ -25,6 +28,7 @@ export class RealtimeGateway implements OnGatewayConnection {
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly drivers: DriversService,
|
||||
@InjectRepository(Trip) private readonly trips: Repository<Trip>,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
@@ -91,9 +95,34 @@ export class RealtimeGateway implements OnGatewayConnection {
|
||||
}
|
||||
|
||||
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
|
||||
/**
|
||||
* `offer` هو حدث **بدء الاتصال** فعلياً (answer/ice/end إشارات تفاوض
|
||||
* لاحقة على نفس المكالمة) — لذا هنا فقط نُسجّل إشارة الاحتيال (docs/17 —
|
||||
* E1). الكتابة على القاعدة مباشرة لا عبر Redis: تكرار المكالمات لا يقارن
|
||||
* بتكرار نبضات الموقع، فلا حاجة لتجميع (نمط H) هنا.
|
||||
*/
|
||||
@SubscribeMessage('call:offer')
|
||||
onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
|
||||
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')
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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],
|
||||
|
||||
Reference in New Issue
Block a user