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:
Hamza-Ayed
2026-07-17 02:23:00 +03:00
co-authored by Claude Opus 4.8
parent b7e91e5de6
commit 9d6b752ea8
28 changed files with 1372 additions and 166 deletions
+103
View File
@@ -0,0 +1,103 @@
// اختبار تسابق المحفظة (docs/17 — I1): يثبت أن المال لا يُفقد ولا يُخلق تحت التزامن.
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات.
//
// لماذا سكربت منفصل: pg-mem في اختبارات jest أحادي الخيط فيثبت صحة الـSQL فقط،
// أما التزامن الحقيقي على نفس الصف فيحتاج Postgres حقيقياً.
//
// التشغيل (حاوية node مؤقتة على شبكة tripz):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/wallet-race-test.mjs 100 5
// الوسائط: <عدد محاولات الخصم المتزامنة> <مبلغ كل خصم>
const BASE = process.env.BASE || 'http://localhost:4010/api';
const TENANT = process.env.TENANT || 'siro';
const ATTEMPTS = parseInt(process.argv[2] || '100', 10);
const AMOUNT = parseFloat(process.argv[3] || '5');
const CODE = '1234';
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* نص غير JSON */ }
return { ok: res.ok, status: res.status, body: json };
}
async function token(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
if (!r.ok) throw new Error(`auth failed: ${r.status} ${JSON.stringify(r.body)}`);
return r.body.access_token;
}
async function main() {
// مبلغ يكفي نصف المحاولات فقط — النصف الآخر *يجب* أن يُرفض.
const expectedSuccesses = Math.floor(ATTEMPTS / 2);
const funded = expectedSuccesses * AMOUNT;
const phone = `0796${String(Date.now()).slice(-6)}`;
const t = await token(phone);
await api('POST', '/wallet/topup', t, { amount: funded });
const before = await api('GET', '/wallet', t);
const startBalance = Number(before.body.balance);
console.log(`المحفظة: ${phone} | الرصيد الابتدائي = ${startBalance}`);
if (startBalance !== funded) {
console.log(`⚠️ الشحن لم يطابق المتوقع (${funded}) — أوقفت الاختبار.`);
process.exit(1);
}
// كل الطلبات تنطلق دفعة واحدة على نفس الصف.
console.log(`إطلاق ${ATTEMPTS} خصم متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
const t0 = Date.now();
const results = await Promise.all(
Array.from({ length: ATTEMPTS }, () =>
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch((e) => ({
ok: false,
status: 0,
body: { message: String(e) },
})),
),
);
const elapsed = Date.now() - t0;
const ok = results.filter((r) => r.ok).length;
const rejected = results.filter((r) => !r.ok).length;
const after = await api('GET', '/wallet', t);
const endBalance = Number(after.body.balance);
const spent = startBalance - endBalance;
console.log('');
console.log(`نجح : ${ok} (المتوقع ${expectedSuccesses})`);
console.log(`رُفض : ${rejected}`);
console.log(`الرصيد : ${startBalance} → ${endBalance} (خُصم ${spent})`);
console.log(`الزمن : ${elapsed}ms`);
console.log('');
// المحكّات الثلاثة
const checks = [
['الرصيد لم يصبح سالباً', endBalance >= 0],
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
];
let failed = 0;
for (const [name, pass] of checks) {
console.log(`${pass ? '✅' : '❌'} ${name}`);
if (!pass) failed++;
}
console.log('');
console.log(failed === 0 ? '✅ لا سباق: المحفظة ذرّية.' : `❌ ${failed} محكّ فشل — يوجد سباق.`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('خطأ:', e.message);
process.exit(1);
});