feat: المجموعة G — Redis خط أول والقاعدة خط احتياط

الأساس: common/cache — كاش-جانبي فوق Redis. مبدأ صارم: فشل Redis لا يُسقط
الطلب (يُسجَّل ويُرجَع للقاعدة) — الكاش تحسين أداء لا مصدر حقيقة.

- G1: لغة المستخدم في Redis (كانت استعلاماً قبل كل إشعار)؛ تُكتب عند
  PATCH /users/me فيصير الإصابة دائمة
- G2/G3: توكنات FCM في Redis — يُبطَل عند register وعند اكتشاف توكن ميت.
  البث الجماعي صار بلا استعلامات قاعدة
- G4: كاش tenant resolve/config + إبطال عند الإنشاء
- G5: كاش التعرفة و ride-types + إبطال صريح عند أي إنشاء
- G6: التقييم يتراكم في Redis، والقاعدة تُكتب مرة واحدة يومياً لكل طرف
  (حجز الكتابة ذرّي بـLua). التأخير مقصود ليبعد الاحتكاك بين الطرفين.
  أُضيف ratings.target_user_id و users.rating — تقييم السائق للراكب كان
  يُخزَّن بلا هدف فلا يُجمَّع أبداً
- كاش الخرائط (route/reverse/geocode) بإحداثيات مقرَّبة 4 خانات (~11م):
  نداء انطلق كان يهيمن على p50 (2.9s). لا يُخزَّن الرجوع لخط مستقيم ولا
  استجابة فاشلة

تصحيح: ادّعيت سابقاً أن tenants.resolve يعمل على كل request — خطأ.
tenant_id يأتي من الـJWT؛ resolve يُستدعى عند الدخول و3 كنترولرات فقط.
الحمل الأثقل فعلياً هو رفع موقع السائق (المجموعة H).

هجرة: RatingsBothParties.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 02:39:34 +03:00
co-authored by Claude Opus 4.8
parent 857652404b
commit a83dd7e5e5
18 changed files with 694 additions and 85 deletions
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { CacheService } from './cache.service';
@Global()
@Module({
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
+68
View File
@@ -0,0 +1,68 @@
import RedisMock from 'ioredis-mock';
import { CacheService, CacheKeys } from './cache.service';
describe('CacheService', () => {
let redis: any;
let cache: CacheService;
beforeEach(() => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
cache = new CacheService(redis);
});
it('يخزّن ويقرأ قيمة مركّبة', async () => {
await cache.set('k', { a: 1, b: ['x'] }, 60);
expect(await cache.get<{ a: number; b: string[] }>('k')).toEqual({ a: 1, b: ['x'] });
});
it('يرجع null لمفتاح غائب', async () => {
expect(await cache.get('nope')).toBeNull();
});
it('wrap: يحمّل من المصدر مرة واحدة ثم يخدم من الكاش', async () => {
const loader = jest.fn().mockResolvedValue({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(loader).toHaveBeenCalledTimes(1);
});
it('wrap: لا يخزّن null — الغياب المؤقّت لا يُثبَّت', async () => {
const loader = jest.fn().mockResolvedValue(null);
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(loader).toHaveBeenCalledTimes(2);
});
it('del يُبطل المفتاح فيعود المصدر', async () => {
const loader = jest.fn().mockResolvedValue('v1');
await cache.wrap('k', 60, loader);
await cache.del('k');
await cache.wrap('k', 60, loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('mget يقرأ عدة مفاتيح ويعطي null للغائب', async () => {
await cache.set('a', 1, 60);
await cache.set('c', 3, 60);
expect(await cache.mget<number>(['a', 'b', 'c'])).toEqual([1, null, 3]);
});
it('فشل Redis لا يُسقط الطلب — يرجع للمصدر', async () => {
const broken = {
get: jest.fn().mockRejectedValue(new Error('redis down')),
set: jest.fn().mockRejectedValue(new Error('redis down')),
} as any;
const c = new CacheService(broken);
const loader = jest.fn().mockResolvedValue('from-db');
expect(await c.wrap('k', 60, loader)).toBe('from-db');
expect(loader).toHaveBeenCalledTimes(1);
});
it('المفاتيح معزولة بالمستأجر', () => {
expect(CacheKeys.userLang('t1', 'u1')).not.toBe(CacheKeys.userLang('t2', 'u1'));
expect(CacheKeys.tariff('t1', 'amman', 'economy')).toBe('tariff:t1:amman:economy');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS } from '../redis/redis.module';
/** أعمار الكاش بالثواني — مجمّعة هنا ليسهل ضبطها (docs/17 — G). */
export const TTL = {
/** المستأجر يتغيّر نادراً جداً. */
tenant: 3600,
/** لغة المستخدم — يرفعها فلاتر عند الفتح. */
userLang: 24 * 3600,
/** توكنات FCM للجهاز. */
deviceTokens: 6 * 3600,
/** التعرفة و ride-types — شبه ثابتة، وتُبطَل صراحةً عند تعديل الأدمن. */
catalog: 900,
/** نتائج الخرائط — عناوين ومسارات لا تتغيّر عملياً. */
maps: 24 * 3600,
} as const;
/**
* كاش-جانبي (cache-aside) فوق Redis — «الريدز خط أول، القاعدة خط احتياط»
* (docs/17 — G).
*
* مبدأ صارم: **فشل Redis لا يُسقط الطلب**. أي خطأ هنا يُسجَّل ويُرجَع للقاعدة،
* لأن الكاش تحسين أداء لا مصدر حقيقة.
*/
@Injectable()
export class CacheService {
private readonly logger = new Logger('Cache');
constructor(@Inject(REDIS) private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
try {
const raw = await this.redis.get(key);
return raw == null ? null : (JSON.parse(raw) as T);
} catch (e: any) {
this.logger.warn(`get ${key} failed: ${e?.message}`);
return null;
}
}
async set(key: string, value: unknown, ttlSec: number): Promise<void> {
try {
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSec);
} catch (e: any) {
this.logger.warn(`set ${key} failed: ${e?.message}`);
}
}
async del(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.redis.del(...keys);
} catch (e: any) {
this.logger.warn(`del failed: ${e?.message}`);
}
}
/**
* يقرأ من الكاش، وعند الغياب يحمّل من المصدر ويخزّن.
* `null` من المصدر لا يُخزَّن — لئلا يُثبَّت غياب مؤقّت لمدة TTL كاملة.
*/
async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T | null>): Promise<T | null> {
const hit = await this.get<T>(key);
if (hit !== null) return hit;
const fresh = await loader();
if (fresh !== null && fresh !== undefined) await this.set(key, fresh, ttlSec);
return fresh;
}
/** قراءة عدة مفاتيح بجولة واحدة — للبث الجماعي (docs/17 — G3). */
async mget<T>(keys: string[]): Promise<(T | null)[]> {
if (keys.length === 0) return [];
try {
const raws = await this.redis.mget(...keys);
return raws.map((raw) => {
if (raw == null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
});
} catch (e: any) {
this.logger.warn(`mget failed: ${e?.message}`);
return keys.map(() => null);
}
}
}
/** مفاتيح الكاش — مجمّعة لتفادي التضارب ولتسهيل الإبطال. */
export const CacheKeys = {
tenant: (slugOrId: string) => `tenant:${slugOrId}`,
userLang: (tenantId: string, userId: string) => `user:lang:${tenantId}:${userId}`,
deviceTokens: (tenantId: string, userId: string) => `user:fcm:${tenantId}:${userId}`,
tariff: (tenantId: string, city: string, serviceClass: string) =>
`tariff:${tenantId}:${city}:${serviceClass}`,
rideTypes: (tenantId: string) => `ridetypes:${tenantId}`,
mapsRoute: (from: string, to: string) => `maps:route:${from}:${to}`,
mapsReverse: (lat: string, lng: string) => `maps:rev:${lat}:${lng}`,
mapsGeocode: (country: string, q: string) => `maps:geo:${country}:${q}`,
};