feat: المجموعة A (زمن حقيقي + FCM + Redis) + إصلاح سباق المحفظة
المجموعة A (docs/17): - A1: FCM على كل انتقال حالة (priority high) + حذف التوكنات الميتة - A2: common/i18n (ar/en) + عمود users.language — الإشعارات بلغة المستخدم - A3: TripStateService — حالة الرحلة الجارية في Redis hash (TTL 6س)؛ الانتقال صار UPDATE شرطي + قراءة واحدة بدل ~5 استعلامات - A4: قبول ذرّي — CAS بـLua في Redis + UPDATE ... WHERE status='searching' كحَكَم نهائي؛ أول سائق يفوز والباقي يُرفضون بلا لمس القاعدة - A5: مجموعة العروض في Redis + بث trip:offer_taken و FCM لبقية السائقين - A6: GET /trips/available — السائق يسحب الطلبات القريبة - A7: FCM data-only بحمولة كاملة للـoverlay I1 — إصلاح سباق المحفظة (ثغرة مالية): - credit/debit كانا read-modify-write على balance بلا قفل → خصمان متزامنان يكتبان فوق بعضهما. صارا UPDATE ذرّي واحد بشرط balance >= :amount، والقيد+الرصيد في معاملة واحدة - wallet_txns.balance_after للتدقيق + CHECK (balance >= 0) كشبكة أمان - إنشاء المحفظة عبر ON CONFLICT DO NOTHING (سباق ثانٍ كان كامناً) الاختبارات تعمل على السيرفر (docs/15): - npm test صار جزءاً من مرحلة builder — فشل اختبار = فشل بناء = لا نشر - pg-mem + ioredis-mock: بلا شبكة وبلا قاعدة حقيقية - scripts/wallet-race-test.mjs للتزامن الحقيقي على السيرفر Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b7e91e5de6
commit
9d6b752ea8
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DeviceToken } from './entities/device-token.entity';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DeviceToken])],
|
||||
imports: [TypeOrmModule.forFeature([DeviceToken]), UsersModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
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';
|
||||
|
||||
export interface PushOptions {
|
||||
/** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */
|
||||
dataOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* إشعارات FCM. تسجيل التوكنات + إرسال أفضل جهد (best-effort).
|
||||
* النصوص تُترجَم حسب لغة المستخدم (docs/17 — A2).
|
||||
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
|
||||
*/
|
||||
@Injectable()
|
||||
@@ -15,6 +23,8 @@ export class NotificationsService {
|
||||
constructor(
|
||||
@InjectRepository(DeviceToken) private readonly tokens: Repository<DeviceToken>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly i18n: I18nService,
|
||||
private readonly users: UsersService,
|
||||
) {}
|
||||
|
||||
async register(tenantId: string, userId: string, token: string, platform = 'android') {
|
||||
@@ -30,30 +40,111 @@ export class NotificationsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* الإرسال المترجَم — الواجهة المفضّلة. `key` مفتاح من كتالوج الترجمة
|
||||
* و`params` متغيّراته؛ `data` حمولة التطبيق (tripId, type, …).
|
||||
*/
|
||||
async sendLocalized(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
params: Record<string, any> = {},
|
||||
data: Record<string, any> = {},
|
||||
opts: PushOptions = {},
|
||||
) {
|
||||
const lang = await this.users.getLanguage(tenantId, userId);
|
||||
const { title, body } = this.i18n.t(lang, key, params);
|
||||
return this.sendToUser(tenantId, userId, title, body, data, opts);
|
||||
}
|
||||
|
||||
/** نفس الشيء لعدة مستخدمين — يُستخدم لبث «الرحلة لم تعد متاحة» (A5). */
|
||||
async sendLocalizedToMany(
|
||||
tenantId: string,
|
||||
userIds: string[],
|
||||
key: string,
|
||||
params: Record<string, any> = {},
|
||||
data: Record<string, any> = {},
|
||||
opts: PushOptions = {},
|
||||
) {
|
||||
await Promise.all(
|
||||
userIds.map((id) =>
|
||||
this.sendLocalized(tenantId, id, key, params, data, opts).catch((e: any) =>
|
||||
this.logger.warn(`push to ${id} failed: ${e?.message}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** إرسال بنص جاهز (غير مترجَم) — للحالات الخاصة فقط. */
|
||||
async sendToUser(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
title: string,
|
||||
body: string,
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- داخلي ----
|
||||
|
||||
private async dispatch(
|
||||
rows: DeviceToken[],
|
||||
title: string,
|
||||
body: string,
|
||||
data: Record<string, any>,
|
||||
opts: PushOptions,
|
||||
) {
|
||||
const key = this.config.get<string>('fcm.serverKey');
|
||||
const rows = await this.tokens.find({ where: { tenant_id: tenantId, user_id: userId } });
|
||||
if (!key || rows.length === 0) {
|
||||
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
|
||||
return;
|
||||
}
|
||||
const endpoint = this.config.get<string>('fcm.endpoint')!;
|
||||
for (const r of rows) {
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
|
||||
body: JSON.stringify({ to: r.token, notification: { title, body }, data }),
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`push failed: ${e?.message}`);
|
||||
}
|
||||
// FCM يقبل نصوصاً فقط في data — والـoverlay يقرأ العنوان/النص من هنا.
|
||||
const payload = this.stringifyData({ ...data, title, body });
|
||||
|
||||
const stale: string[] = [];
|
||||
await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
|
||||
body: JSON.stringify({
|
||||
to: r.token,
|
||||
// أولوية عالية: يوقظ التطبيق في الخلفية (شرط الـoverlay).
|
||||
priority: 'high',
|
||||
...(opts.dataOnly ? {} : { notification: { title, body } }),
|
||||
data: payload,
|
||||
}),
|
||||
});
|
||||
const json: any = await res.json().catch(() => null);
|
||||
if (json?.results?.[0]?.error === 'NotRegistered' ||
|
||||
json?.results?.[0]?.error === 'InvalidRegistration') {
|
||||
stale.push(r.token);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`push failed: ${e?.message}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
|
||||
if (stale.length > 0) {
|
||||
await this.tokens.delete({ token: In(stale) });
|
||||
this.logger.debug(`removed ${stale.length} stale token(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
private stringifyData(data: Record<string, any>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v == null) continue;
|
||||
out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user