Files
SS/WALLET.md
T

12 KiB

WALLET.md — تفصيل نظام التسجيل والمحفظة (SMM Panel)

ملف مكمّل لـ AGENTS.md. يركّز حصراً على جزئين حسّاسين: نظام تسجيل الدخول/الحساب وصحة المحفظة (Wallet Integrity) — أي ضمان أن كل ريال/دولار يدخل أو يخرج من رصيد المستخدم محسوب صح، موثّق، وغير قابل للتلاعب أو التكرار.


1. نظام الحساب (Auth) بالتفصيل

1.1 التسجيل (Register)

POST /auth/register
Body: { username, email, password }
  • تحقق من صيغة الإيميل، وأن username و email غير مستخدمين (UNIQUE constraint + تحقق على مستوى التطبيق).
  • كلمة المرور: حد أدنى 8 خانات، تُشفّر فوراً بـ bcrypt (salt rounds ≥ 10) — لا تُخزَّن ولا تُطبع باللوق أبداً.
  • عند الإنشاء: balance = 0.0000 صراحةً (لا تترك القيمة الافتراضية ضمنية في الكود).
  • (اختياري لكن يُفضّل): إرسال إيميل تفعيل قبل السماح بالشحن أو الشراء، لتقليل الحسابات الوهمية.

1.2 الدخول (Login)

POST /auth/login
Body: { email, password }
  • قارن الباسورد بـ bcrypt.compare.
  • عند النجاح: أصدر JWT يحوي { user_id, role } بمدة صلاحية قصيرة (مثلاً 15 دقيقة) + refresh token طويل مخزّن كـ httpOnly cookie أو في جدول refresh_tokens (قابل للإبطال عند تسجيل الخروج/تغيير الباسورد).
  • Rate limiting إلزامي: مثلاً 5 محاولات فاشلة / 15 دقيقة لكل IP + لكل حساب، ثم قفل مؤقت. هذا يمنع محاولات تخمين الباسورد للوصول لرصيد أحد.
  • لا ترجّع أبداً رسالة تفرّق بين "الإيميل غير موجود" و"الباسورد غلط" — رسالة موحدة لمنع تعداد الحسابات (user enumeration).

1.3 الجلسة والصلاحيات

  • Middleware auth: يتحقق من JWT ويحقن req.user.
  • Middleware requireActive: يرفض أي عملية مالية (شحن/شراء) إذا status = banned.
  • Middleware admin: يتحقق role = admin لكل مسار إداري.
  • عند أي عملية حساسة على المحفظة، تحقق من هوية المستخدم من الـ JWT فقط — لا تثق أبداً بـ user_id قادم من body/query.

1.4 نسيان كلمة المرور

  • توليد token عشوائي (32+ byte) + صلاحية قصيرة (15-30 دقيقة) + تخزينه مُشفّر (hash) في DB.
  • عند إعادة التعيين: أبطل كل الـ refresh tokens القديمة لهذا المستخدم (يقفل أي جلسة مسروقة).

2. صحة المحفظة (Wallet Integrity) — الجزء الأهم

الهدف: رصيد المستخدم = مجموع كل حركاته في transactions، دائماً، بدون استثناء. إذا صار فرق بين users.balance ومجموع transactions، فهذا يعني وجود باغ خطير أو تلاعب.

2.1 القواعد الذهبية

  1. كل تغيير على balance يمر عبر خدمة واحدة فقط: wallet.service.js — ممنوع أي كود آخر (controller أو route) يعدّل balance مباشرة بـ SQL.
  2. كل عملية = DB Transaction واحدة (atomic) تشمل: قفل الصف، التحقق، التعديل، وتسجيل الحركة في transactions — الأربعة سوا أو ولا وحدة.
  3. استخدم SELECT ... FOR UPDATE على صف المستخدم لمنع Race Condition (طلبين بنفس اللحظة يشتروا بنفس الرصيد).
  4. DECIMAL دائماً، لا FLOAT أبداً — الأخطاء العشرية في FLOAT تسبب فروقات مالية حقيقية مع الوقت.
  5. لا رصيد سالب أبداً: تحقق balance >= amount داخل نفس الـ Transaction بعد القفل، وليس قبل.
  6. كل حركة توثّق balance_after — يعطيك سجل تدقيق كامل تقدر تعيد بناء الرصيد منه في أي لحظة تاريخية.
  7. Idempotency: أي عملية قابلة لإعادة الإرسال (retry من الشبكة، دبل كليك، webhook مكرر) لازم يكون إلها مفتاح فريد يمنع تكرارها.

2.2 مثال Pseudo-code لخصم رصيد (شراء خدمة)

async function chargeForOrder(userId, amount, orderRef) {
  return db.transaction(async (trx) => {
    // 1. قفل صف المستخدم
    const user = await trx('users').where({ id: userId }).forUpdate().first();

    // 2. تحقق من الحالة والرصيد داخل القفل
    if (user.status !== 'active') throw new AppError('الحساب موقوف');
    if (Number(user.balance) < Number(amount)) throw new InsufficientBalanceError();

    const newBalance = round4(Number(user.balance) - Number(amount));

    // 3. عدّل الرصيد
    await trx('users').where({ id: userId }).update({ balance: newBalance });

    // 4. سجّل الحركة (idempotency عبر reference فريد)
    await trx('transactions').insert({
      user_id: userId,
      type: 'order',
      amount: -amount,
      balance_after: newBalance,
      reference: orderRef,   // مثال: "order:1234"
      note: 'خصم مقابل طلب خدمة',
    });

    return newBalance;
  }); // commit تلقائي، أو rollback كامل عند أي خطأ
}
  • إذا فشل إرسال الطلب لمزوّد الخدمة بعد نجاح الخصم: نفّذ عملية عكسية (refund) بنفس الآلية أعلاه، لا تعدّل الرصيد يدوياً.
  • ضع UNIQUE constraint على transactions.reference (أو composite user_id + reference + type) — هذا يمنع تسجيل نفس الحركة مرتين حتى لو صار retry.

2.3 الشحن اليدوي — كيف تتأكد إن المصاري "صحيحة" فعلاً

هذا أهم نقطة طلبتها، فهاي التفاصيل:

أ) منع تكرار نفس إثبات التحويل:

  • deposits.tx_ref لازم UNIQUE — يمنع مستخدم يقدّم نفس رقم التحويل/الهاش مرتين (أو مستخدمين مختلفين يستخدموا نفس الإثبات).

ب) الموافقة تتم فقط من الأدمن (لا auto-approve إلا لو عندك تكامل حقيقي مع بوابة/بلوكتشين):

PATCH /admin/deposits/:id  { action: 'approve' | 'reject', note }
  • عند approve، داخل DB Transaction واحدة:
    1. قفل صف deposits (FOR UPDATE) وتأكد status = pending (يمنع أدمن ثاني يوافق مرتين بنفس اللحظة).
    2. قفل صف users.
    3. balance += deposit.amount.
    4. أنشئ transaction بـ type=deposit، reference = "deposit:" + deposit.id.
    5. حدّث deposits.status = approved, reviewed_by = admin.id.
    6. Commit واحد للكل.
  • لا تسمح بتعديل amount بعد الإنشاء — إذا المبلغ غلط، الأدمن يرفض ويطلب من المستخدم تقديم طلب جديد. هذا يحافظ على أن deposits.amount = المصدر الوحيد للحقيقة.

ج) لو رح تضيف تحقق تلقائي لاحقاً (USDT مثلاً):

  • تحقق من الـ tx hash على البلوكتشين فعلياً (عدد التأكيدات، العنوان المستلم، المبلغ يطابق تماماً) قبل أي auto-approve.
  • خزّن نتيجة التحقق (raw response) في عمود إضافي للتدقيق.
  • لا تثق بأي مبلغ يرسله المستخدم في الفورم — تحقق منه مقابل البلوكتشين/بوابة الدفع نفسها.

د) تعديل رصيد يدوي من الأدمن (PATCH /admin/users/:id):

  • يمر بنفس آلية wallet.service.js (قفل + تسجيل transaction بـ note يوضح السبب وreference يربط بمن نفّذ التعديل) — ممنوع UPDATE مباشر على عمود balance.

2.4 التسوية الدورية (Reconciliation)

مهمة cron (يومية مثلاً) تتحقق:

SELECT user_id, SUM(amount) AS calculated
FROM transactions
GROUP BY user_id

وتقارن الناتج مع users.balance لكل مستخدم. أي فرق = تنبيه فوري للأدمن (لا تصحيح تلقائي — تحقيق يدوي أولاً).

2.5 حالات حافة لازم تُختبر

الحالة السلوك المطلوب
طلبين شراء بنفس اللحظة من نفس المستخدم الثاني يفشل أو ينتظر القفل، لا يمرّان معاً إذا الرصيد يكفي لواحد فقط
فشل الشبكة بعد الخصم وقبل استلام رد المزوّد job دوري يتحقق من حالة الطلب عند المزوّد، وإن لم يُنشأ فعلياً يرد المبلغ
Retry تلقائي من الفرونت (دبل كليك على "شراء") idempotency key يمنع الخصم مرتين لنفس الطلب
أدمن يوافق على نفس طلب الشحن مرتين (تبويبين مفتوحين) القفل (FOR UPDATE) + شرط status=pending يمنع التكرار
مستخدم يقدّم رقم تحويل مستخدم سابقاً UNIQUE على tx_ref يرفضه فوراً
رصيد يوشك يصير سالب بسبب سباق (race) التحقق من الرصيد يتم بعد القفل مباشرة، ضمن نفس الـ transaction

3. جدول Idempotency (اختياري لكن مُستحسن)

لأي عملية POST حساسة (شراء، شحن)، خلي الفرونت يرسل Idempotency-Key فريد لكل محاولة:

العمود النوع
key VARCHAR(64) PK
user_id BIGINT
endpoint VARCHAR(100)
response_snapshot JSON
created_at TIMESTAMP

إذا وصل نفس المفتاح مرة ثانية، رجّع نفس النتيجة المخزّنة بدل تنفيذ العملية من جديد.


4. اختبارات إلزامية لمنطق المحفظة

  • شراء بمبلغ أكبر من الرصيد → يُرفض، لا يتغيّر الرصيد.
  • شراءين متزامنين (parallel) بمبلغ كل واحد = نص الرصيد → واحد فقط ينجح.
  • فشل إرسال الطلب للمزوّد بعد الخصم → رصيد المستخدم يرجع كما كان (refund تلقائي).
  • موافقة أدمن على شحن مرتين بالتوازي → إضافة واحدة فقط للرصيد.
  • تقديم tx_ref مكرر → رفض فوري بدون لمس الرصيد.
  • بعد N عملية عشوائية (شحن/خصم/استرجاع)، SUM(transactions.amount) == users.balance دائماً.

5. ملخص مسارات الحساب والمحفظة

POST /auth/register
POST /auth/login
POST /auth/logout           → يُبطل الـ refresh token
POST /auth/forgot-password
POST /auth/reset-password

GET  /me                    → الرصيد + بيانات الحساب
GET  /transactions          → كشف حساب كامل (مع balance_after لكل حركة)
POST /deposits               → تقديم طلب شحن (amount, method, tx_ref)
GET  /deposits               → طلبات الشحن الخاصة بي وحالتها

PATCH /admin/deposits/:id    → موافقة/رفض (atomic كما بالقسم 2.3)
PATCH /admin/users/:id       → تعديل رصيد يدوي (يمر بـ wallet.service.js حصراً)

6. ملاحظة للوكيل البرمجي

عند تنفيذ أي كود يلمس balance، اسأل نفسك دائماً: "هل هذا داخل DB transaction مقفول بـ FOR UPDATE، وهل يسجّل صف في transactions؟" — إذا الجواب لا لأي منهما، الكود غير جاهز للإنتاج بغض النظر عن أي شيء آخر.