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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 20:36:41 +03:30
co-authored by Claude Opus 4.8
parent 447482c2ea
commit 61ac775175
10 changed files with 175 additions and 24 deletions
+2
View File
@@ -184,6 +184,8 @@ Get approved **root** comments for a doctor (replies are nested under each root
**Permission:** `PUBLIC` **Permission:** `PUBLIC`
> **صفحه‌بندی:** `?page` و `?limit` (پیش‌فرض ۵۰، حداکثر ۱۰۰)؛ پاسخ شامل `data.meta` (`totalRecords`/`totalPages`/`currentPage`) است. آرایه‌ی نظرات همچنان در `data.data` است. همین صفحه‌بندی روی `GET /api/v1/admin/comments/pending` هم اعمال می‌شود.
### Path Parameters ### Path Parameters
| Param | Type | Description | | Param | Type | Description |
|-------|------|-------------| |-------|------|-------------|
+3 -1
View File
@@ -141,10 +141,12 @@ Request a settlement (withdrawal from wallet to bank account).
## GET `/api/v1/settlement` ## GET `/api/v1/settlement`
Get authenticated user's settlement requests. Get authenticated user's settlement requests (paginated, newest first).
**Permission:** `AUTH` **Permission:** `AUTH`
> **صفحه‌بندی:** `?page` و `?limit` (پیش‌فرض ۵۰، حداکثر ۱۰۰). پاسخ علاوه بر `data.data` یک `data.meta` (`totalRecords`/`totalPages`/`currentPage`) دارد؛ پاکت قبلی دست‌نخورده است.
### Response `200` ### Response `200`
```json ```json
{ {
+3 -3
View File
@@ -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`. | | ✅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 | | 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 | | 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 | | 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 | admin moderation large backlog → paginate | | 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 | popular doctor comments → paginate | | 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 | | ⚠️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`. | | ✅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. | | ⚠️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. |
+32 -6
View File
@@ -267,19 +267,30 @@ class RatingController extends BaseController
] ]
)] )]
#[Route('/api/v1/comments/{doctorUuid}', methods: ['GET'])] #[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); $doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) { if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); 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( $comments = array_map(
fn(Comment $c) => $c->toArray(), 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( #[OA\Delete(
@@ -347,10 +358,25 @@ class RatingController extends BaseController
)] )]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/comments/pending', methods: ['GET'])] #[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()); $page = max(1, (int) $request->query->get('page', 1));
return $this->success(['data' => $comments]); $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( #[OA\Post(
+30 -8
View File
@@ -5,6 +5,7 @@ namespace App\Rating\Repository;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Rating\Entity\Comment; use App\Rating\Entity\Comment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
class CommentRepository extends ServiceEntityRepository class CommentRepository extends ServiceEntityRepository
@@ -19,15 +20,29 @@ class CommentRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']); 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 */ /** @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 // Two fetch-join passes (not the product, to avoid a likes×replies
// cartesian) hydrate everything toArray() touches, so serialization // cartesian) hydrate everything toArray() touches, so serialization
// triggers no per-comment lazy loads (N+1): pass 1 = roots + author + // triggers no per-comment lazy loads (N+1): pass 1 = roots + author +
// likes, pass 2 = replies + their author + likes, merged into the same // likes, pass 2 = replies + their author + likes, merged into the same
// managed root entities. // managed root entities. Paginator(fetchJoinCollection) keeps the LIMIT
$roots = $this->createQueryBuilder('c') // paginating by root comment, not by joined like rows.
$qb = $this->createQueryBuilder('c')
->addSelect('u', 'l') ->addSelect('u', 'l')
->leftJoin('c.user', 'u') ->leftJoin('c.user', 'u')
->leftJoin('c.likes', 'l') ->leftJoin('c.likes', 'l')
@@ -37,8 +52,10 @@ class CommentRepository extends ServiceEntityRepository
->setParameter('doctor', $doctor) ->setParameter('doctor', $doctor)
->setParameter('status', Comment::STATUS_APPROVED) ->setParameter('status', Comment::STATUS_APPROVED)
->orderBy('c.createdAt', 'DESC') ->orderBy('c.createdAt', 'DESC')
->getQuery() ->setFirstResult($offset)
->getResult(); ->setMaxResults($limit);
$roots = iterator_to_array(new Paginator($qb, fetchJoinCollection: true));
if ($roots !== []) { if ($roots !== []) {
// Also initialise each reply's own replies collection (empty in a // Also initialise each reply's own replies collection (empty in a
@@ -58,10 +75,15 @@ class CommentRepository extends ServiceEntityRepository
return $roots; return $roots;
} }
/** @return Comment[] */ public function countPending(): int
public function findPending(): array
{ {
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(); } public function save(Comment $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
@@ -203,14 +203,25 @@ class SettlementController extends BaseController
] ]
)] )]
#[Route('/api/v1/settlement', methods: ['GET'])] #[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( $settlements = array_map(
fn(Settlement $s) => $s->toArray(), 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( #[OA\Get(
@@ -20,9 +20,14 @@ class SettlementRepository extends ServiceEntityRepository
} }
/** @return Settlement[] */ /** @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 */ /** Balance = sum of credits - sum of debits from wallet_transactions */
+4 -1
View File
@@ -14,6 +14,9 @@ class SendCodeMobileRateLimitTest extends ApiTestCase
{ {
$this->client->disableReboot(); $this->client->disableReboot();
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT); $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 = []; $statuses = [];
for ($i = 0; $i < 6; $i++) { for ($i = 0; $i < 6; $i++) {
@@ -21,7 +24,7 @@ class SendCodeMobileRateLimitTest extends ApiTestCase
$this->client->request( $this->client->request(
'POST', 'POST',
'/api/v1/user/send-code', '/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]), content: json_encode(['mobile' => $mobile]),
); );
$statuses[] = $this->client->getResponse()->getStatusCode(); $statuses[] = $this->client->getResponse()->getStatusCode();
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Tests\Rating;
use App\Doctor\Entity\Doctor;
use App\Rating\Entity\Comment;
use App\Tests\ApiTestCase;
/**
* Both the public per-doctor comment list and the admin pending-comment list
* must page rather than return every row.
*/
class CommentPaginationTest extends ApiTestCase
{
public function testPublicListPaginates(): void
{
$doctor = new Doctor($this->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']);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Tests\Settlement;
use App\Settlement\Entity\Settlement;
use App\Tests\ApiTestCase;
/**
* GET /api/v1/settlement must page rather than return every settlement, exposing
* totals in meta without breaking the data.data envelope.
*/
class SettlementListPaginationTest extends ApiTestCase
{
public function testPaginatesAndReportsTotals(): void
{
$user = $this->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']);
}
}