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>
156 lines
12 KiB
Markdown
156 lines
12 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 خانه → صفحهبندی واقعی | ✅ **انجام شد 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، حالت بارگذاری، و «نتیجهای نیست»
|
||
که باید با صفحهی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این
|
||
است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش
|
||
تنها جایی است که تست واحد کفایت نمیکند.
|
||
|
||
---
|
||
|
||
**برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بیریسکاند و هیچچیز را نمیشکنند. اگر
|
||
بگویی «برو»، از آنها شروع میکنم و بعد از هر مرحله گزارش میدهم. اگر عجله داری،
|
||
مرحله ۳ همانجایی است که کاربر فوراً تفاوت را حس میکند (باز شدن صفحه محصول).
|