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
@@ -5,12 +5,19 @@ import { In, Repository } from 'typeorm';
import { DeviceToken } from './entities/device-token.entity';
import { I18nService } from '../../common/i18n/i18n.service';
import { UsersService } from '../users/users.service';
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
export interface PushOptions {
/** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */
dataOnly?: boolean;
}
/** ما يُخزَّن في Redis لكل مستخدم — التوكن والمنصّة فقط، لا صف القاعدة كاملاً. */
interface CachedToken {
token: string;
platform: string;
}
/**
* إشعارات FCM. تسجيل التوكنات + إرسال أفضل جهد (best-effort).
* النصوص تُترجَم حسب لغة المستخدم (docs/17 — A2).
@@ -25,19 +32,30 @@ export class NotificationsService {
private readonly config: ConfigService,
private readonly i18n: I18nService,
private readonly users: UsersService,
private readonly cache: CacheService,
) {}
async register(tenantId: string, userId: string, token: string, platform = 'android') {
const existing = await this.tokens.findOne({ where: { token } });
let saved: DeviceToken;
if (existing) {
// قد يكون التوكن كان لمستخدم آخر على نفس الجهاز — نُبطل كاشه أيضاً.
const previousUser = existing.user_id;
const previousTenant = existing.tenant_id;
existing.tenant_id = tenantId;
existing.user_id = userId;
existing.platform = platform;
return this.tokens.save(existing);
saved = await this.tokens.save(existing);
if (previousUser !== userId || previousTenant !== tenantId) {
await this.cache.del(CacheKeys.deviceTokens(previousTenant, previousUser));
}
} else {
saved = await this.tokens.save(
this.tokens.create({ tenant_id: tenantId, user_id: userId, token, platform }),
);
}
return this.tokens.save(
this.tokens.create({ tenant_id: tenantId, user_id: userId, token, platform }),
);
await this.cache.del(CacheKeys.deviceTokens(tenantId, userId));
return saved;
}
/**
@@ -84,14 +102,36 @@ export class NotificationsService {
data: Record<string, any> = {},
opts: PushOptions = {},
) {
const rows = await this.tokens.find({ where: { tenant_id: tenantId, user_id: userId } });
return this.dispatch(rows, title, body, data, opts);
const rows = await this.tokensFor(tenantId, userId);
return this.dispatch(tenantId, userId, rows, title, body, data, opts);
}
// ---- داخلي ----
/**
* توكنات المستخدم — Redis خط أول (docs/17 — G2). كانت استعلام قاعدة قبل كل
* إشعار. القائمة الفارغة تُخزَّن أيضاً (مستخدم بلا جهاز لا يستحق استعلاماً
* متكرراً)؛ `register` و«التوكن الميت» يُبطلان المفتاح.
*/
private async tokensFor(tenantId: string, userId: string): Promise<CachedToken[]> {
const cached = await this.cache.wrap<CachedToken[]>(
CacheKeys.deviceTokens(tenantId, userId),
TTL.deviceTokens,
async () => {
const rows = await this.tokens.find({
where: { tenant_id: tenantId, user_id: userId },
select: { token: true, platform: true },
});
return rows.map((r) => ({ token: r.token, platform: r.platform }));
},
);
return cached ?? [];
}
private async dispatch(
rows: DeviceToken[],
tenantId: string,
userId: string,
rows: CachedToken[],
title: string,
body: string,
data: Record<string, any>,
@@ -133,8 +173,10 @@ export class NotificationsService {
);
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
// والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح.
if (stale.length > 0) {
await this.tokens.delete({ token: In(stale) });
await this.cache.del(CacheKeys.deviceTokens(tenantId, userId));
this.logger.debug(`removed ${stale.length} stale token(s)`);
}
}