Files
tehran_taj/docs/CATALOG-PAGINATION-PLAN.md
s.esmaeiliandCursor afb068c643 feat: paginate customer orders and polish the dashboard hero
Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 13:11:42 +03:30

12 KiB
Raw Permalink Blame History

طرح — صفحه‌بندی واقعی کاتالوگ (۱۶ هزار رکورد)

تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12) — طرح؛ منتظر تأیید

مالک: «دیتاها در اپ واقعی تا ۱۶ هزار رکورد می‌رود و گرفتن همه یک‌جا اشتباه است، حتماً باید پیجینیشن داشته باشد.»

موافقم. با این عدد، وضعیت فعلی قابل دفاع نیست. این سند می‌گوید دقیقاً چه چیزی شکسته است، چرا کار از «اضافه کردن take/skip» بزرگ‌تر است، و با چه ترتیبی بی‌ریسک انجام می‌شود.


۱. وضعیت امروز (بررسی‌شده در کد، نه حدس)

لایه واقعیت مدرک
مسیر API GET /catalog/products هیچ پارامتری نمی‌پذیرد catalog.controller.ts:10-12
کوئری یک findMany بدون take/skip، همراه variants+images+category+brand+collections catalog.service.ts:19-42
کش سرور کل خروجی، ۳۰ ثانیه در حافظه، با هر نوشتن باطل catalog-cache.service.ts
«صفحه‌بندی» اپ PAGE_SIZE = 20 فقط یک پنجره رندر روی آرایه‌ی کامل است: slice(0, visibleCount) SearchView.vue:204-206، NewestView.vue:55-57
در هر اسکرول صفر درخواست، ۲۰ کارت بیشتر رندر می‌شود همان

ایندکس‌ها — یافته‌ی مهم: مدل Product هیچ ایندکسی ندارد (نه isActive، نه categoryId، نه brandId، نه createdAt) و ProductVariant ایندکسی روی productId ندارد. با ۱۶ هزار رکورد، صفحه‌بندی بدون ایندکس همان کندی را می‌دهد؛ پس ایندکس بخشی از این کار است، نه یک بهبود جداگانه.

۲. چرا کار از «اضافه کردن take/skip» بزرگ‌تر است

هفت مصرف‌کننده روی فرض «کل کاتالوگ در حافظه است» نوشته شده‌اند. سه‌تا بحرانی‌اند — هر سه کل لیست را می‌گیرند تا یک چیز پیدا کنند:

جا الان چه می‌کند با ۱۶ هزار رکورد
useProductDetail کل کاتالوگ را می‌گیرد تا یک محصول را find کند بازکردن هر محصول = دانلود کل کاتالوگ
cartStore.init کل کاتالوگ برای همگام‌سازی ۲-۳ قلم سبد هر بار باز شدن اپ
configStore.init کل کاتالوگ فقط برای لیست دسته‌ها صفحه خانه
SearchView متن/دسته/برند/بازه وزن را روی کلاینت فیلتر می‌کند فیلتر روی داده‌ی ناقص = نتیجه غلط
Filters.vue گزینه‌های فیلتر را از کل لیست مشتق می‌کند گزینه‌ها ناقص می‌شوند
FavoritesView علاقه‌مندی‌ها را با find در کل لیست پیدا می‌کند علاقه‌مندیِ خارج از صفحه دیده نمی‌شود
useOrderItemProduct عنوان محصولِ آیتم سفارش را از کل لیست می‌خواند سفارش قدیمی بی‌عنوان می‌شود

نتیجه: اگر فقط take/skip به مسیر اضافه شود، جستجو و فیلتر بی‌صدا غلط می‌شوند (روی ۲۰ رکورد اول فیلتر می‌کنند) و این بدتر از کندی است. پس هر مصرف‌کننده باید مسیر مخصوص خودش را بگیرد.

۳. طراحی پیشنهادی

(الف) چهار مسیر، به‌جای یک مسیر

مسیر برای چه چرا جدا
GET /catalog/products?page=&limit=&search=&categoryId=&brandId=&minGram=&maxGram=&sort= لیست و جستجو فیلتر و صفحه‌بندی سمت سرور؛ تنها راه درست بودن نتیجه
GET /catalog/products/:id صفحه محصول یک رکورد، نه ۱۶ هزار
GET /catalog/facets گزینه‌های فیلتر (دسته‌ها، برندها، بازه وزن) کوچک، distinct/aggregate؛ جایگزین مشتق‌کردن از کل لیست
POST /catalog/products/by-ids سبد، علاقه‌مندی، آیتم‌های سفارش چند شناسه مشخص در یک درخواست

شکل پاسخ لیست: { rows, total, page, limit }. عمداً از TableService استفاده نمی‌کنم: آن برای قرارداد XTable پنل ادمین است و شکل پاسخش ({rows, query:{...}}) برای اپ بی‌مورد پیچیده است.

(ب) سازگاری عقب‌رو — مهم

مسیر فعلی نباید بشکند: پنل ادمین، اسکریپت‌های smoke و نسخه‌های نصب‌شده‌ی اپ از آن استفاده می‌کنند.

  • بدون page/limit ⇒ رفتار امروز (کل لیست). پس هیچ مصرف‌کننده‌ای نمی‌شکند.
  • با page/limit ⇒ پاسخ صفحه‌بندی‌شده.
  • در لاگ، درخواست بدون page روی کاتالوگ بزرگ هشدار می‌دهد تا بفهمیم چه کسی هنوز قدیمی است.

(ج) کش سرور — بازطراحی

کش فعلی یک خروجی را نگه می‌دارد (value: {at, data}). با پارامترها این نادرست می‌شود: صفحهٔ ۲ جواب صفحهٔ ۱ را می‌گیرد.

پیشنهاد: کلید کش = رشته‌ی پارامترهای نرمال‌شده، با سقف تعداد کلید (مثلاً ۵۰، LRU) تا حافظه بی‌نهایت رشد نکند. باطل‌سازی موجود (middleware پریزما) همه را با هم پاک می‌کند — که درست است، چون هر نوشتنی می‌تواند هر صفحه‌ای را عوض کند.

⚠️ سقف کلید لازم است: بدون آن، هر ترکیب فیلترِ کاربران یک ردیف کش می‌سازد و حافظه‌ی سرور به‌تدریج پر می‌شود.

(د) ایندکس‌ها (مهاجرت)

Product:        @@index([isActive, createdAt])   ← لیست و مرتب‌سازی پیش‌فرض
                @@index([categoryId])
                @@index([brandId])
ProductVariant: @@index([productId])             ← الان نیست؛ هر include یک اسکن
ProductImage:   @@index([productId])

جستجوی متنی: contains فارسی روی ۱۶ هزار ردیف با ایندکس معمولی سریع نمی‌شود. پیشنهاد فعلی: همان contains با ایندکس بالا (برای ۱۶ هزار قابل قبول است). اگر کند بود، مرحله‌ی بعد pg_trgm است — ولی اول اندازه‌گیری، بعد اضافه کردن.

(ه) سمت اپ

کار تغییر
catalogStore از «یک آرایه‌ی کامل» به «صفحات + total»؛ load() صفحه می‌گیرد، loadMore() صفحه بعد
SearchView / NewestView IntersectionObserver به‌جای زیاد کردن visibleCount، درخواست صفحه بعد می‌زند؛ فیلتر/جستجو با debounce به سرور می‌رود و از صفحه ۱ شروع می‌کند
Filters.vue گزینه‌ها از /catalog/facets
useProductDetail GET /catalog/products/:id
cartStore.init by-ids با شناسه‌های سبد
FavoritesView by-ids با شناسه‌های علاقه‌مندی
useOrderItemProduct by-ids، یا بهتر: عنوان از خود OrderItem snapshot (اگر دارد)
configStore دسته‌ها از /catalog/facets

اندازه صفحه: همان ۲۰ که الان پنجره‌ی رندر است، تا تجربه عوض نشود.

۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)

مرحله کار وضعیت
۱ مهاجرت ایندکس‌ها ✅ انجام شد (cbb93de) — Product هیچ ایندکسی نداشت
۲ مسیرهای سرور + تست ✅ انجام شد (cbb93de) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست
۳ صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش ✅ انجام شد — پنج مصرف‌کننده منتقل شد
۴ SearchView / NewestView / rails خانه → صفحه‌بندی واقعی ✅ انجام شد 2026-10-17 — هر دو اپ؛ facets + listProductsPaged؛ multi-brand با brandId=a,b
۵ حذف خوانش کل کاتالوگ و هشدار لاگ ⏳ catalog.load هنوز یک صفحهٔ ۱۰۰تایی می‌گیرد برای callerهای باقی‌مانده؛ هشدار لاگ بعد از پاک‌سازی

یادداشت مرحله ۳ (انجام‌شده)

«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: OrderItem فقط productVariantId دارد نه productId، پس ?ids= جوابش نمی‌داد. سرور همان را با یک include برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش قبلی که ردیف را خالی می‌گذاشت.

یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به /facets باعث شد memory.init() دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ HomeView حالا صریحاً کاتالوگ را بار می‌کند.

مرحله ۴ هنوز باقی است — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.

۵. دو چیز که باید بدانی

۱. مرجع صفحه‌بندی سرور داشت. ApiMobileController برای لیست محصولات صفحه‌بندی می‌فرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار بازگشت به رفتار مرجع است، نه قابلیت جدید — در ماتریس به‌عنوان STUB-03 ثبت است.

۲. ریسک واقعی این کار، جستجو و فیلتر است. امروز روی کلاینت فوری و دقیق کار می‌کنند. منتقل‌شدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجه‌ای نیست» که باید با صفحه‌ی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این است که وقتی رسیدیم، یک بار با هم در مرورگر امتحانش کنیم — چون این بخش تنها جایی است که تست واحد کفایت نمی‌کند.


برای تصمیم تو: مرحله ۱ و ۲ کاملاً بی‌ریسک‌اند و هیچ‌چیز را نمی‌شکنند. اگر بگویی «برو»، از آن‌ها شروع می‌کنم و بعد از هر مرحله گزارش می‌دهم. اگر عجله داری، مرحله ۳ همان‌جایی است که کاربر فوراً تفاوت را حس می‌کند (باز شدن صفحه محصول).