189 lines
12 KiB
Markdown
189 lines
12 KiB
Markdown
# 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 لخصم رصيد (شراء خدمة)
|
|
```js
|
|
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 (يومية مثلاً) تتحقق:
|
|
```sql
|
|
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؟"* — إذا الجواب لا لأي منهما، الكود غير جاهز للإنتاج بغض النظر عن أي شيء آخر.
|