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:
co-authored by
Claude Opus 4.8
parent
857652404b
commit
a83dd7e5e5
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user