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
+32 -6
View File
@@ -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(
+30 -8
View File
@@ -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(); }
@@ -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(
@@ -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 */