Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse.InquiryAnswerDetailResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryUpdateRequest;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -56,8 +57,9 @@ public InquiryDetailResponse getInquiryDetail(UserJpaEntity user, Long postId) {
}

@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public Page<InquiryResponse> getMyInquiry(Long userId, Pageable pageable) {
return inquiryJpaRepository.searchMyInquiry(userId, pageable);
public Page<InquiryListResponse> getMyInquiry(Long userId, Pageable pageable) {
return inquiryJpaRepository.searchMyInquiry(userId,
pageable);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package life.mosu.mosuserver.domain.inquiry.repository;

import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -10,5 +11,5 @@ public interface InquiryQueryRepository {
Page<InquiryResponse> searchInquiries(InquiryStatus status, String sortField, boolean asc,
Pageable pageable);

Page<InquiryResponse> searchMyInquiry(Long userId, Pageable pageable);
Page<InquiryListResponse> searchMyInquiry(Long userId, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus;
import life.mosu.mosuserver.domain.inquiry.entity.QInquiryJpaEntity;
import life.mosu.mosuserver.domain.inquiry.repository.InquiryQueryRepository;
import life.mosu.mosuserver.domain.inquiryAnswer.entity.QInquiryAnswerJpaEntity;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryAnswerResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand All @@ -29,6 +32,7 @@ public class InquiryJpaRepositoryImpl implements InquiryQueryRepository {
private final JPAQueryFactory queryFactory;
private final EntityManager entityManager;
private final QInquiryJpaEntity inquiry = QInquiryJpaEntity.inquiryJpaEntity;
private final QInquiryAnswerJpaEntity inquiryAnswer = QInquiryAnswerJpaEntity.inquiryAnswerJpaEntity;

@Override
public Page<InquiryResponse> searchInquiries(
Expand All @@ -38,40 +42,42 @@ public Page<InquiryResponse> searchInquiries(
Pageable pageable
) {

JPAQuery<Tuple> query = baseQuery(inquiry)
.where(buildStatusCondition(inquiry, status))
.orderBy(buildOrderByCondition(sortField, asc))
.offset(pageable.getOffset())
.limit(pageable.getPageSize());
JPAQuery<Tuple> query = baseQuery()
.where(buildStatusCondition(inquiry, status));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The baseQuery method was refactored to use the class field inquiry instead of taking it as a parameter, which is a good change. For consistency, the buildStatusCondition method should also be updated to not take the QInquiryJpaEntity inquiry parameter and instead use the class field. This would align it with the implementation of buildOrderByCondition and improve code clarity.


long total = getTotalCount(query, inquiry.count());

List<InquiryResponse> content = query.fetch().stream()
.map(this::mapToResponse)
List<InquiryResponse> content = query
.orderBy(buildOrderByCondition(sortField, asc))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch().stream()
.map(this::mapToInquiryResponse)
.toList();

return new PageImpl<>(content, pageable, total);
}

@Override
public Page<InquiryResponse> searchMyInquiry(Long userId, Pageable pageable) {
JPAQuery<Tuple> query = baseQuery(inquiry)
public Page<InquiryListResponse> searchMyInquiry(Long userId, Pageable pageable) {
JPAQuery<Tuple> query = baseQueryWithAnswer()
.where(inquiry.userId.eq(userId));

long total = getTotalCount(query, inquiry.count());
long total = getTotalCount(query, inquiry.countDistinct());

List<InquiryResponse> content = query
List<InquiryListResponse> content = query
.orderBy(inquiry.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch().stream()
.map(this::mapToResponse)
.map(this::mapToInquiryListResponse)
.toList();

return new PageImpl<>(content, pageable, total);
}
Comment on lines 61 to 77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Use id-based distinct count and validate 1:1 assumption to avoid paging errors

  • Prefer countDistinct on the id expression to avoid dialect-specific issues:
-        long total = getTotalCount(query, inquiry.countDistinct());
+        long total = getTotalCount(query, inquiry.id.countDistinct());
  • If an inquiry can have multiple answers (1:N), the current content query will duplicate inquiries within a page, causing missing unique items and inconsistent paging. If the domain guarantees 1:1 (Inquiry↔Answer), please confirm this is enforced at the JPA mapping and DB level (unique constraint/index). Otherwise, consider a 2-step fetch (page inquiries, then batch-load answers by IN clause) to guarantee uniqueness without relying on joins.

Script to verify relationship and constraints, and confirm service/docs alignment:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect InquiryAnswer entity for 1:1 mapping or uniqueness
fd -a "InquiryAnswerJpaEntity.java" | xargs -r -I{} rg -n --no-heading -A3 -B3 "@OneToOne|@ManyToOne|unique|UniqueConstraint|inquiryId|inquiry" {}

# Check BaseTimeEntity.formatDate implementation for null-safety
fd -a "BaseTimeEntity.java" | xargs -r -I{} rg -n --no-heading -A2 -B2 "static .*formatDate|DateTimeFormatter|null" {}

# Confirm service method return type
rg -n "interface\\s+InquiryQueryRepository|class\\s+InquiryService" -A 50 -S | rg -n "searchMyInquiry\\(|getMyInquiry\\(" -n -S

Length of output: 2736


Use id-based distinct count and prevent duplicate paging issues

In InquiryJpaRepositoryImpl.searchMyInquiry, switch from inquiry.countDistinct() to inquiry.id.countDistinct() so that the total count reflects unique inquiry IDs and avoids dialect-specific quirks:

- long total = getTotalCount(query, inquiry.countDistinct());
+ long total = getTotalCount(query, inquiry.id.countDistinct());

Currently, InquiryAnswerJpaEntity defines inquiryId as a simple @Column (no @OneToOne or unique constraint), so an inquiry with multiple answers will produce duplicate rows in content (and incorrect page sizes). To address this, choose one of the following:

  • Enforce 1:1: Add a unique constraint on inquiry_id in the inquiry_answer table (via JPA @Table(uniqueConstraints=…) or a DB migration) and map it with @OneToOne in the entity.
  • Use a two-step fetch:
    1. Query only distinct inquiry IDs (paged).
    2. Batch-load answers for those IDs and map to InquiryListResponse.

Either approach will ensure unique items per page and consistent paging.

🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/infra/persistence/jpa/InquiryJpaRepositoryImpl.java
around lines 61 to 77, the total count query uses inquiry.countDistinct(), which
can cause duplicate counts due to multiple answers per inquiry. Change this to
use inquiry.id.countDistinct() to count unique inquiry IDs. Additionally, to
prevent duplicate rows in the paged content caused by multiple answers per
inquiry, either enforce a one-to-one relationship by adding a unique constraint
on inquiry_id in the inquiry_answer table and mapping it with @OneToOne, or
implement a two-step fetch: first query distinct inquiry IDs with pagination,
then batch-load their answers and map to InquiryListResponse. This will ensure
unique items per page and correct paging behavior.



private JPAQuery<Tuple> baseQuery(QInquiryJpaEntity inquiry) {
private JPAQuery<Tuple> baseQuery() {
return queryFactory
.select(
inquiry.id,
Expand All @@ -85,6 +91,27 @@ private JPAQuery<Tuple> baseQuery(QInquiryJpaEntity inquiry) {

}

private JPAQuery<Tuple> baseQueryWithAnswer() {
return queryFactory
.select(
inquiry.id,
inquiry.title,
inquiry.content,
inquiry.author,
inquiry.status,
inquiry.createdAt,
inquiryAnswer.id,
inquiryAnswer.title,
inquiryAnswer.content,
inquiryAnswer.author,
inquiryAnswer.createdAt,
inquiryAnswer.updatedAt
)
.from(inquiry)
.leftJoin(inquiryAnswer)
.on(inquiryAnswer.inquiryId.eq(inquiry.id));
}

private BooleanExpression buildStatusCondition(QInquiryJpaEntity inquiry,
InquiryStatus status) {
return status != null ? inquiry.status.eq(status) : null;
Expand All @@ -108,7 +135,7 @@ private <T> long getTotalCount(JPAQuery<T> query, Expression<Long> countExpressi
).orElse(0L);
}

private InquiryResponse mapToResponse(Tuple tuple) {
private InquiryResponse mapToInquiryResponse(Tuple tuple) {
InquiryStatus status = tuple.get(inquiry.status);
return new InquiryResponse(
tuple.get(inquiry.id),
Expand All @@ -119,4 +146,22 @@ private InquiryResponse mapToResponse(Tuple tuple) {
formatDate(tuple.get(inquiry.createdAt))
);
}


private InquiryListResponse mapToInquiryListResponse(Tuple tuple) {
InquiryResponse inquiryDto = mapToInquiryResponse(tuple);
InquiryAnswerResponse answerDto = null;

if (tuple.get(inquiryAnswer.id) != null) {
answerDto = new InquiryAnswerResponse(
tuple.get(inquiryAnswer.title),
tuple.get(inquiryAnswer.content),
tuple.get(inquiryAnswer.author),
formatDate(tuple.get(inquiryAnswer.createdAt)),
formatDate(tuple.get(inquiryAnswer.updatedAt))
);
}

return InquiryListResponse.of(inquiryDto, answerDto);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import life.mosu.mosuserver.global.util.ApiResponseWrapper;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryUpdateRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand Down Expand Up @@ -54,12 +54,13 @@ public ResponseEntity<ApiResponseWrapper<Void>> update(

@GetMapping("/my")
@PreAuthorize("isAuthenticated() and hasRole('USER')")
public ResponseEntity<ApiResponseWrapper<Page<InquiryResponse>>> getMyInquiries(
public ResponseEntity<ApiResponseWrapper<Page<InquiryListResponse>>> getMyInquiries(
@UserId Long userId,
@PageableDefault(size = 10) Pageable pageable
) {
Page<InquiryResponse> inquiries = inquiryService.getMyInquiry(userId, pageable);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "내 질문 목록 조회 성공", inquiries));
Page<InquiryListResponse> inquiries = inquiryService.getMyInquiry(userId, pageable);
return ResponseEntity.ok(
ApiResponseWrapper.success(HttpStatus.OK, "내 질문 목록 조회 성공", inquiries));
}

@GetMapping("/{postId}")
Expand All @@ -68,7 +69,8 @@ public ResponseEntity<ApiResponseWrapper<InquiryDetailResponse>> getInquiryDetai
@AuthenticationPrincipal PrincipalDetails principalDetails,
@PathVariable Long postId) {

InquiryDetailResponse inquiry = inquiryService.getInquiryDetail(principalDetails.user(), postId);
InquiryDetailResponse inquiry = inquiryService.getInquiryDetail(principalDetails.user(),
postId);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "질문 상세 조회 성공",
inquiry));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import life.mosu.mosuserver.global.util.ApiResponseWrapper;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
Expand All @@ -37,7 +37,7 @@ ResponseEntity<ApiResponseWrapper<Void>> create(
@ApiResponse(responseCode = "200", description = "내 문의글 목록 조회 성공",
content = @Content(schema = @Schema(implementation = Page.class)))
})
ResponseEntity<ApiResponseWrapper<Page<InquiryResponse>>> getMyInquiries(
ResponseEntity<ApiResponseWrapper<Page<InquiryListResponse>>> getMyInquiries(
@Parameter(description = "사용자 ID", required = true) Long userId,
@Parameter(description = "페이지 정보") Pageable pageable
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package life.mosu.mosuserver.presentation.inquiry.dto;

public record InquiryAnswerResponse(
String title,
String content,
String author,
String createdAt,
String updatedAt
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package life.mosu.mosuserver.presentation.inquiry.dto;

public record InquiryListResponse(
InquiryResponse inquiry,
InquiryAnswerResponse reply
) {

public static InquiryListResponse of(
InquiryResponse inquiry,
InquiryAnswerResponse reply
) {
return new InquiryListResponse(inquiry, reply);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package life.mosu.mosuserver.presentation.inquiry.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import life.mosu.mosuserver.domain.inquiry.entity.InquiryJpaEntity;

@Schema(description = "1:1 문의 응답 DTO")
public record InquiryResponse(
Expand All @@ -24,14 +23,5 @@ public record InquiryResponse(
String createdAt
) {

public static InquiryResponse of(InquiryJpaEntity inquiry) {
return new InquiryResponse(
inquiry.getId(),
inquiry.getTitle(),
inquiry.getContent(),
inquiry.getAuthor(),
inquiry.getStatus().getStatusName(),
inquiry.getCreatedAt()
);
}

}