AMSAhub — سوق للخدمات المستقلة

React.jsNode.jsExpressMongoDBStripeREST API· 3 دقائق قراءة

سوق للخدمات المستقلة تخدم فيه المنصة نفسها نيّتين متقابلتين: من يبيع مهارة ومن يشتريها. يتصفح الطرفان الإعلانات نفسها، ويتبادلان الرسائل في الخيوط نفسها، ويقرآن التقييمات نفسها — لكنهما لا يريدان رؤيتها بالطريقة نفسها تقريبًا أبدًا.

المشكلة والقيود

الصعوبة في سوق ذي وجهين ليست في عمليات الإنشاء والقراءة والتعديل والحذف، بل في أن المشتري والبائع ينتظران إجابات مختلفة من بيانات متطابقة، وأن المال جزء من المعادلة. ومن ذلك نشأ قيدان: الدور الذي تُنفَّذ به الطلبية لا يمكن أن يأتي من العميل أبدًا، ولا المبلغ المحصَّل كذلك. وجاء قيد ثالث من صفحات الإعلانات — فشبكة بطاقات تعرض تقييمات بالنجوم لا تحتمل تجميع مجموعة المراجعات مع كل عملية عرض.

النهج

  • نموذج User واحد يحمل حقلًا منطقيًا isSeller مضمَّنًا في حمولة JWT عند تسجيل الدخول. عندها يرشّح getOrders حسب الدور المقروء من الرمز — نقطة نهاية واحدة، وإجابتان صحيحتان، دون أي وسيط دور يرسله العميل ويمكن تزويره.
  • يعيش رمز JWT في كعكة httpOnly لا في localStorage، فلا تستطيع السكربتات في المتصفح قراءته.
  • إنشاء PaymentIntent من Stripe في الخادم، مع قراءة المبلغ من سجل الإعلان المحفوظ — فلا يستقبل المتصفح سوى clientSecret ولا يصرّح بسعر أبدًا.
  • تقييمات غير مُطبَّعة على الإعلان في صورة زوج تراكمي totalStars + starNumber، فتحسب البطاقة متوسطها من عددين صحيحين تملكهما أصلًا.
  • نمذجة المحادثات والرسائل كمجموعتين منفصلتين، ما يُبقي تحميل قائمة الخيوط مستقلًا عن حجم الرسائل داخلها.
client (React)httpOnly JWT cookieExpress APIrole read from JWTamount from listingMongoDBlistings · orders ·reviews · messagesStripePaymentIntent

الوصف الكامل:

  1. client (React) — httpOnly JWT cookie → Express API — role read from JWT — amount from listing
  2. Express API — role read from JWT — amount from listing → MongoDB — listings · orders · — reviews · messages
  3. Express API — role read from JWT — amount from listing → Stripe — PaymentIntent
حدٌّ واحد وقراران لا يتخذهما العميل أبدًا: الدور الذي يعمل به الطلب يُقرأ من الـ JWT، والمبلغ المحصَّل يُقرأ من البطاقة المخزَّنة — والمتصفح لا يستقبل سوى clientSecret.

المفاضلات

Decision

تقييم غير مُطبَّع على الإعلان بدل تجميع المراجعات عند القراءة: عددان صحيحان على المستند الذي تجلبه البطاقة أصلًا، بدل ربط لكل بطاقة في الشبكة — مع تقبّل وجوب تحديث الزوج ضمن معاملة واحدة مع كل مراجعة جديدة، وأن حذف مراجعة يستلزم تصحيحًا صريحًا لا مجرد اختفاء من إعادة العدّ.

Decision

الدور محمول في الرمز بدل إعادة قراءته من قاعدة البيانات مع كل طلب: يحذف عملية قراءة من كل استدعاء موثّق، مقابل تجمّد الدور طوال عمر الرمز — فالبائع الذي يغيّر وضعه يبقى على الواجهة القديمة حتى يُعاد إصدار الرمز.

المفاضلة الأولى من هاتين تحمل ثمنها داخل جملة تابعة: التقييم المحذوف يحتاج تصحيحًا صريحًا. ها هي الثنائية مباشرةً — أضِف تقييمات فتبقى صحيحة، واحذف واحدًا ولاحظ كيف تنحرف:

بطاقة خدمة4.08 (12)ما تعرضه بطاقة داخل الشبكة
مخزَّن على مستند البطاقةtotalStars 49 · starNumber 124.08
معاد حسابه من مجموعة التقييمات12 تقييمًا4.08

الثنائية متطابقة مع المجموعة — كل إضافة حدّثت الاثنين معًا.

أضف تقييمًا

المستندات المقروءة لعرض هذه الشبكة: 24 مع الثنائية المخزَّنة · 312 بإعادة الحساب لكل بطاقة

عرض توضيحي لنموذج البيانات، يُحسب مباشرةً داخل متصفحك — نفس التحديث ذي العددين الذي تنفّذه المنصة. ليست بطاقات حقيقية ولا تقييمات حقيقية ولا أزمنة استعلام مقيسة.

النتائج

دورة سوق عملية: نشر إعلان، والتحاور بشأنه، والدفع عبر Stripe، وترك مراجعة — مع اتخاذ قرارات التفويض والتسعير في الخادم بدل ائتمان العميل عليها.

ما كنت سأفعله بشكل مختلف

نقل إنشاء الطلب إلى خطّاف ويب (webhook) من Stripe. فكما هو الآن، يُكتب سطر الطلب عند إنشاء PaymentIntent، أي قبل تأكيد الدفع، ما يعني أن سلة متروكة تخلّف طلبًا محفوظًا لا يخفيه سوى مرشّح isCompleted — البيانات صادقة لكن المجموعة ليست نظيفة، والمرجع الحاسم يجب أن يكون نداء مزوّد الدفع نفسه. وكنت سأنقل أيضًا العملة usd المكتوبة في الشيفرة إلى الإعدادات.