From 61ac775175daae547e7487df1d75ca095ae5ef96 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 28 Jun 2026 20:36:41 +0330 Subject: [PATCH] perf(settlement,rating): paginate 3 unbounded list endpoints (M10-M12) - M10 GET /settlement: was unbounded; add page/limit + countByUser + data.meta. - M11 GET /admin/comments/pending: paginate findPending + countPending. - M12 GET /comments/{doctor}: paginate the fetch-joined roots query via Paginator(fetchJoinCollection) + countApprovedRootsByDoctor. All keep the existing { data: { data: [...] } } envelope and add data.meta (backward compatible). Default limit 50 / max 100. Regressions: SettlementListPaginationTest, CommentPaginationTest (both fail without the limits). Also de-flaked SendCodeMobileRateLimitTest (randomised the IP block so the persistent per-IP limiter buckets don't accumulate across runs). Co-Authored-By: Claude Opus 4.8 --- docs/api/rating.md | 2 + docs/api/settlement.md | 4 +- docs/audit-backlog.md | 6 +-- src/Rating/Controller/RatingController.php | 38 +++++++++++--- src/Rating/Repository/CommentRepository.php | 38 +++++++++++--- .../Controller/SettlementController.php | 17 +++++-- .../Repository/SettlementRepository.php | 9 +++- tests/Auth/SendCodeMobileRateLimitTest.php | 5 +- tests/Rating/CommentPaginationTest.php | 50 +++++++++++++++++++ .../SettlementListPaginationTest.php | 30 +++++++++++ 10 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 tests/Rating/CommentPaginationTest.php create mode 100644 tests/Settlement/SettlementListPaginationTest.php diff --git a/docs/api/rating.md b/docs/api/rating.md index f5446eac..f474767c 100644 --- a/docs/api/rating.md +++ b/docs/api/rating.md @@ -184,6 +184,8 @@ Get approved **root** comments for a doctor (replies are nested under each root **Permission:** `PUBLIC` +> **صفحه‌بندی:** `?page` و `?limit` (پیش‌فرض ۵۰، حداکثر ۱۰۰)؛ پاسخ شامل `data.meta` (`totalRecords`/`totalPages`/`currentPage`) است. آرایه‌ی نظرات همچنان در `data.data` است. همین صفحه‌بندی روی `GET /api/v1/admin/comments/pending` هم اعمال می‌شود. + ### Path Parameters | Param | Type | Description | |-------|------|-------------| diff --git a/docs/api/settlement.md b/docs/api/settlement.md index a3769913..e4197db2 100644 --- a/docs/api/settlement.md +++ b/docs/api/settlement.md @@ -141,10 +141,12 @@ Request a settlement (withdrawal from wallet to bank account). ## GET `/api/v1/settlement` -Get authenticated user's settlement requests. +Get authenticated user's settlement requests (paginated, newest first). **Permission:** `AUTH` +> **صفحه‌بندی:** `?page` و `?limit` (پیش‌فرض ۵۰، حداکثر ۱۰۰). پاسخ علاوه بر `data.data` یک `data.meta` (`totalRecords`/`totalPages`/`currentPage`) دارد؛ پاکت قبلی دست‌نخورده است. + ### Response `200` ```json { diff --git a/docs/audit-backlog.md b/docs/audit-backlog.md index f49c7722..2faaf672 100644 --- a/docs/audit-backlog.md +++ b/docs/audit-backlog.md @@ -63,9 +63,9 @@ _None outstanding._ | ✅M7 | Refresh token not rotated on use (same raw token 30d), never re-checks user status | src/Auth/Controller/AuthController.php:477-480 · TokenService.php:33-45 | security-auth | **DONE** — single-use rotation (revoke old + issue new) + suspended-user (status!=1) rejected. `tests/Auth/RefreshTokenRotationTest`. | | M8 | N+1: secretary list lazy-loads secretary/doctor ManyToOne per row | src/Secretary/Controller/SecretaryController.php:192,213 | perf-nplus1 | Profiler secretary list → ~2 queries/row | | M9 | N+1: billing claims lazy `items` + `insuranceRepo->find()` per claim in enrichClaims | src/Billing/Controller/BillingController.php:~49,68 | perf-nplus1 | GET claims → items+insurance query/claim | -| M10 | Unbounded list: `listMine` settlements `findByUser` no limit | src/Settlement/Controller/SettlementController.php:193 | perf-pagination | GET settlement list → paginate | -| M11 | Unbounded list: admin `pendingComments` `findPending` no limit | src/Rating/Controller/RatingController.php:350 | perf-pagination | admin moderation large backlog → paginate | -| M12 | Unbounded list: public `listComments` per-doctor no limit | src/Rating/Controller/RatingController.php:270 | perf-pagination | popular doctor comments → paginate | +| ✅M10 | Unbounded list: `listMine` settlements `findByUser` no limit | src/Settlement/Controller/SettlementController.php:193 | perf-pagination | **DONE** — page/limit + countByUser + data.meta. `tests/Settlement/SettlementListPaginationTest` | +| ✅M11 | Unbounded list: admin `pendingComments` `findPending` no limit | src/Rating/Controller/RatingController.php:350 | perf-pagination | **DONE** — findPending(limit,offset)+countPending+meta. `tests/Rating/CommentPaginationTest::testAdminPendingListPaginates` | +| ✅M12 | Unbounded list: public `listComments` per-doctor no limit | src/Rating/Controller/RatingController.php:270 | perf-pagination | **DONE** — Paginator(fetchJoinCollection) page/limit + countApprovedRootsByDoctor + meta. `tests/Rating/CommentPaginationTest::testPublicListPaginates` | | ⚠️M13 | Missing index: `ServiceItem.section_id` FK fully unindexed (entity has zero indexes) | src/ClinicService/Entity/ServiceItem.php:23-24 | perf-index | EXPLAIN findBySection → no full scan | | ✅M14 | Missing index: `WalletTransaction(user_id, type)` composite for per-type SUM | src/Settlement/Entity/WalletTransaction.php:38-39 | perf-index | **DONE** — added composite `idx_wallet_user_type` + migration. `InfraSmokeTest::testWalletUserTypeIndexExists`. | | ⚠️M15 | Missing index: `ClinicDoctorInvitation.doctor_id` FK unindexed | src/ClinicInvitation/Entity/ClinicDoctorInvitation.php:41-42 | perf-index | **FALSE POSITIVE** — `doctor_id` has a FK → auto-indexed (IDX_26DCCFEB87F4FB17 exists). No change. | diff --git a/src/Rating/Controller/RatingController.php b/src/Rating/Controller/RatingController.php index ee841d3f..184f5b00 100644 --- a/src/Rating/Controller/RatingController.php +++ b/src/Rating/Controller/RatingController.php @@ -267,19 +267,30 @@ class RatingController extends BaseController ] )] #[Route('/api/v1/comments/{doctorUuid}', methods: ['GET'])] - public function listComments(string $doctorUuid): JsonResponse + public function listComments(string $doctorUuid, Request $request): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); } + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 50))); + $comments = array_map( fn(Comment $c) => $c->toArray(), - $this->commentRepo->findApprovedRootsByDoctor($doctor) + $this->commentRepo->findApprovedRootsByDoctor($doctor, $limit, ($page - 1) * $limit) ); + $total = $this->commentRepo->countApprovedRootsByDoctor($doctor); - return $this->success(['data' => $comments]); + return $this->success([ + 'data' => $comments, + 'meta' => [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / $limit), + 'currentPage' => $page, + ], + ]); } #[OA\Delete( @@ -347,10 +358,25 @@ class RatingController extends BaseController )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/comments/pending', methods: ['GET'])] - public function pendingComments(): JsonResponse + public function pendingComments(Request $request): JsonResponse { - $comments = array_map(fn(Comment $c) => $c->toArray(), $this->commentRepo->findPending()); - return $this->success(['data' => $comments]); + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 50))); + + $comments = array_map( + fn(Comment $c) => $c->toArray(), + $this->commentRepo->findPending($limit, ($page - 1) * $limit) + ); + $total = $this->commentRepo->countPending(); + + return $this->success([ + 'data' => $comments, + 'meta' => [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / $limit), + 'currentPage' => $page, + ], + ]); } #[OA\Post( diff --git a/src/Rating/Repository/CommentRepository.php b/src/Rating/Repository/CommentRepository.php index 52df8a86..93a8d614 100644 --- a/src/Rating/Repository/CommentRepository.php +++ b/src/Rating/Repository/CommentRepository.php @@ -5,6 +5,7 @@ namespace App\Rating\Repository; use App\Doctor\Entity\Doctor; use App\Rating\Entity\Comment; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\Persistence\ManagerRegistry; class CommentRepository extends ServiceEntityRepository @@ -19,15 +20,29 @@ class CommentRepository extends ServiceEntityRepository return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']); } + public function countApprovedRootsByDoctor(Doctor $doctor): int + { + return (int) $this->createQueryBuilder('c') + ->select('COUNT(c.id)') + ->where('c.doctor = :doctor') + ->andWhere('c.status = :status') + ->andWhere('c.parent IS NULL') + ->setParameter('doctor', $doctor) + ->setParameter('status', Comment::STATUS_APPROVED) + ->getQuery() + ->getSingleScalarResult(); + } + /** @return Comment[] approved root comments (no parent) for a doctor */ - public function findApprovedRootsByDoctor(Doctor $doctor): array + public function findApprovedRootsByDoctor(Doctor $doctor, int $limit = 50, int $offset = 0): array { // Two fetch-join passes (not the product, to avoid a likes×replies // cartesian) hydrate everything toArray() touches, so serialization // triggers no per-comment lazy loads (N+1): pass 1 = roots + author + // likes, pass 2 = replies + their author + likes, merged into the same - // managed root entities. - $roots = $this->createQueryBuilder('c') + // managed root entities. Paginator(fetchJoinCollection) keeps the LIMIT + // paginating by root comment, not by joined like rows. + $qb = $this->createQueryBuilder('c') ->addSelect('u', 'l') ->leftJoin('c.user', 'u') ->leftJoin('c.likes', 'l') @@ -37,8 +52,10 @@ class CommentRepository extends ServiceEntityRepository ->setParameter('doctor', $doctor) ->setParameter('status', Comment::STATUS_APPROVED) ->orderBy('c.createdAt', 'DESC') - ->getQuery() - ->getResult(); + ->setFirstResult($offset) + ->setMaxResults($limit); + + $roots = iterator_to_array(new Paginator($qb, fetchJoinCollection: true)); if ($roots !== []) { // Also initialise each reply's own replies collection (empty in a @@ -58,10 +75,15 @@ class CommentRepository extends ServiceEntityRepository return $roots; } - /** @return Comment[] */ - public function findPending(): array + public function countPending(): int { - return $this->findBy(['status' => Comment::STATUS_PENDING], ['createdAt' => 'ASC']); + return $this->count(['status' => Comment::STATUS_PENDING]); + } + + /** @return Comment[] */ + public function findPending(int $limit = 50, int $offset = 0): array + { + return $this->findBy(['status' => Comment::STATUS_PENDING], ['createdAt' => 'ASC'], $limit, $offset); } public function save(Comment $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } diff --git a/src/Settlement/Controller/SettlementController.php b/src/Settlement/Controller/SettlementController.php index 4a75ce0f..1400267f 100644 --- a/src/Settlement/Controller/SettlementController.php +++ b/src/Settlement/Controller/SettlementController.php @@ -203,14 +203,25 @@ class SettlementController extends BaseController ] )] #[Route('/api/v1/settlement', methods: ['GET'])] - public function listMine(#[CurrentUser] User $user): JsonResponse + public function listMine(Request $request, #[CurrentUser] User $user): JsonResponse { + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 50))); + $settlements = array_map( fn(Settlement $s) => $s->toArray(), - $this->settlementRepo->findByUser($user) + $this->settlementRepo->findByUser($user, $limit, ($page - 1) * $limit) ); + $total = $this->settlementRepo->countByUser($user); - return $this->success(['data' => $settlements]); + return $this->success([ + 'data' => $settlements, + 'meta' => [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / $limit), + 'currentPage' => $page, + ], + ]); } #[OA\Get( diff --git a/src/Settlement/Repository/SettlementRepository.php b/src/Settlement/Repository/SettlementRepository.php index 01e6e4a0..f5296db4 100644 --- a/src/Settlement/Repository/SettlementRepository.php +++ b/src/Settlement/Repository/SettlementRepository.php @@ -20,9 +20,14 @@ class SettlementRepository extends ServiceEntityRepository } /** @return Settlement[] */ - public function findByUser(User $user): array + public function findByUser(User $user, int $limit = 50, int $offset = 0): array { - return $this->findBy(['user' => $user], ['createdAt' => 'DESC']); + return $this->findBy(['user' => $user], ['createdAt' => 'DESC'], $limit, $offset); + } + + public function countByUser(User $user): int + { + return $this->count(['user' => $user]); } /** Balance = sum of credits - sum of debits from wallet_transactions */ diff --git a/tests/Auth/SendCodeMobileRateLimitTest.php b/tests/Auth/SendCodeMobileRateLimitTest.php index fae9877f..792fa190 100644 --- a/tests/Auth/SendCodeMobileRateLimitTest.php +++ b/tests/Auth/SendCodeMobileRateLimitTest.php @@ -14,6 +14,9 @@ class SendCodeMobileRateLimitTest extends ApiTestCase { $this->client->disableReboot(); $mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT); + // a fresh random IP block per run so the persistent per-IP limiter buckets + // don't accumulate across runs and fire early. + $ipBase = random_int(1, 250); $statuses = []; for ($i = 0; $i < 6; $i++) { @@ -21,7 +24,7 @@ class SendCodeMobileRateLimitTest extends ApiTestCase $this->client->request( 'POST', '/api/v1/user/send-code', - server: ['REMOTE_ADDR' => "10.20.30.$i", 'CONTENT_TYPE' => 'application/json'], + server: ['REMOTE_ADDR' => "10.$ipBase.30.$i", 'CONTENT_TYPE' => 'application/json'], content: json_encode(['mobile' => $mobile]), ); $statuses[] = $this->client->getResponse()->getStatusCode(); diff --git a/tests/Rating/CommentPaginationTest.php b/tests/Rating/CommentPaginationTest.php new file mode 100644 index 00000000..5ef7a498 --- /dev/null +++ b/tests/Rating/CommentPaginationTest.php @@ -0,0 +1,50 @@ +createUser(['ROLE_DOCTOR']), 'دکتر'); + $this->em->persist($doctor); + for ($i = 0; $i < 7; $i++) { + $this->em->persist((new Comment($this->createUser(), $doctor, "نظر $i"))->approve()); + } + $this->em->flush(); + + $this->client->request('GET', '/api/v1/comments/' . $doctor->getUuid() . '?limit=5'); + $body = json_decode($this->client->getResponse()->getContent(), true); + + $this->assertSame(200, $this->responseCode()); + $this->assertCount(5, $body['data']['data']); + $this->assertSame(7, $body['data']['meta']['totalRecords']); + } + + public function testAdminPendingListPaginates(): void + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر'); + $this->em->persist($doctor); + $admin = $this->createUser(['ROLE_ADMIN']); + for ($i = 0; $i < 7; $i++) { + // not approved → pending + $this->em->persist(new Comment($this->createUser(), $doctor, "در انتظار $i")); + } + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/admin/comments/pending?limit=5', $admin); + + $this->assertSame(200, $this->responseCode()); + $this->assertCount(5, $body['data']['data']); + // pending is global (not per-doctor); db_test may hold others → at least 7 + $this->assertGreaterThanOrEqual(7, $body['data']['meta']['totalRecords']); + } +} diff --git a/tests/Settlement/SettlementListPaginationTest.php b/tests/Settlement/SettlementListPaginationTest.php new file mode 100644 index 00000000..45bbe11f --- /dev/null +++ b/tests/Settlement/SettlementListPaginationTest.php @@ -0,0 +1,30 @@ +createUser(); + for ($i = 0; $i < 7; $i++) { + $this->em->persist(new Settlement($user, 100_000)); + } + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/settlement?limit=5', $user); + $this->assertSame(200, $this->responseCode()); + $this->assertCount(5, $body['data']['data']); + $this->assertSame(7, $body['data']['meta']['totalRecords']); + + $page2 = $this->authJson('GET', '/api/v1/settlement?limit=5&page=2', $user); + $this->assertCount(2, $page2['data']['data']); + } +}