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 القواعد الذهبية
- كل تغيير على
balanceيمر عبر خدمة واحدة فقط:wallet.service.js— ممنوع أي كود آخر (controller أو route) يعدّلbalanceمباشرة بـ SQL. - كل عملية = DB Transaction واحدة (atomic) تشمل: قفل الصف، التحقق، التعديل، وتسجيل الحركة في
transactions— الأربعة سوا أو ولا وحدة. - استخدم
SELECT ... FOR UPDATEعلى صف المستخدم لمنع Race Condition (طلبين بنفس اللحظة يشتروا بنفس الرصيد). - DECIMAL دائماً، لا FLOAT أبداً — الأخطاء العشرية في FLOAT تسبب فروقات مالية حقيقية مع الوقت.
- لا رصيد سالب أبداً: تحقق
balance >= amountداخل نفس الـ Transaction بعد القفل، وليس قبل. - كل حركة توثّق
balance_after— يعطيك سجل تدقيق كامل تقدر تعيد بناء الرصيد منه في أي لحظة تاريخية. - 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(أو compositeuser_id + reference + type) — هذا يمنع تسجيل نفس الحركة مرتين حتى لو صار retry.
2.3 الشحن اليدوي — كيف تتأكد إن المصاري "صحيحة" فعلاً
هذا أهم نقطة طلبتها، فهاي التفاصيل:
أ) منع تكرار نفس إثبات التحويل:
deposits.tx_refلازمUNIQUE— يمنع مستخدم يقدّم نفس رقم التحويل/الهاش مرتين (أو مستخدمين مختلفين يستخدموا نفس الإثبات).
ب) الموافقة تتم فقط من الأدمن (لا auto-approve إلا لو عندك تكامل حقيقي مع بوابة/بلوكتشين):
PATCH /admin/deposits/:id { action: 'approve' | 'reject', note }
- عند
approve، داخل DB Transaction واحدة:- قفل صف
deposits(FOR UPDATE) وتأكدstatus = pending(يمنع أدمن ثاني يوافق مرتين بنفس اللحظة). - قفل صف
users. balance += deposit.amount.- أنشئ
transactionبـtype=deposit، reference = "deposit:" + deposit.id. - حدّث
deposits.status = approved,reviewed_by = admin.id. - 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؟" — إذا الجواب لا لأي منهما، الكود غير جاهز للإنتاج بغض النظر عن أي شيء آخر.