156 lines
11 KiB
Markdown
156 lines
11 KiB
Markdown
# طرح — صفحهبندی واقعی کاتالوگ (۱۶ هزار رکورد)
|
||||
|
|
|
|||
|
|
تاریخ: ۱۴۰۵/۰۷/۲۰ (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 خانه → صفحهبندی واقعی | ⏳ **باقی** — پرریسکترین (فیلتر + اسکرول) |
|
|||
|
|
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرفکنندهای نمانده |
|
|||
|
|
|
|||
|
|
### یادداشت مرحله ۳ (انجامشده)
|
|||
|
|
|
|||
|
|
«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط
|
|||
|
|
`productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمیداد. سرور همان را
|
|||
|
|
با یک `include` برمیگرداند — که برای محصول حذفشده هم کار میکند، برعکس روش
|
|||
|
|
قبلی که ردیف را خالی میگذاشت.
|
|||
|
|
|
|||
|
|
یک باگ هم در حین کار ساخته و گرفته شد: انتقال دستهها به `/facets` باعث شد
|
|||
|
|
`memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا
|
|||
|
|
صریحاً کاتالوگ را بار میکند.
|
|||
|
|
|
|||
|
|
**مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل
|
|||
|
|
کاتالوگ را میگیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.
|
|||
|
|
|
|||
|
|
## ۵. دو چیز که باید بدانی
|
|||
|
|
|
|||
|
|
**۱. مرجع صفحهبندی سرور داشت.** `ApiMobileController` برای لیست محصولات
|
|||
|
|
صفحهبندی میفرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار
|
|||
|
|
**بازگشت به رفتار مرجع** است، نه قابلیت جدید — در ماتریس بهعنوان STUB-03 ثبت
|
|||
|
|
است.
|
|||
|
|
|
|||
|
|
**۲. ریسک واقعی این کار، جستجو و فیلتر است.** امروز روی کلاینت فوری و دقیق
|
|||
|
|
کار میکنند. منتقلشدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجهای نیست»
|
|||
|
|
که باید با صفحهی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این
|
|||
|
|
است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش
|
|||
|
|
تنها جایی است که تست واحد کفایت نمیکند.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بیریسکاند و هیچچیز را نمیشکنند. اگر
|
|||
|
|
بگویی «برو»، از آنها شروع میکنم و بعد از هر مرحله گزارش میدهم. اگر عجله داری،
|
|||
|
|
مرحله ۳ همانجایی است که کاربر فوراً تفاوت را حس میکند (باز شدن صفحه محصول).
|