diff --git a/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java
index 4b3224b0..aacae60b 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java
@@ -21,15 +21,24 @@ public interface SpringDataAgentRunRepository extends JpaRepository findActiveRuns(
+ List findActiveRuns(
@Param("conversationId") Long conversationId,
@Param("ownerUserId") Long ownerUserId,
@Param("terminalStatuses") List terminalStatuses,
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java b/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java
index 412678d1..bb946735 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java
@@ -107,7 +107,7 @@ public java.util.Optional findActiveTask(Long conversationId, Long u
conversationId, userId, TERMINAL_STATUSES,
org.springframework.data.domain.PageRequest.of(0, 1))
.stream().findFirst()
- .map(run -> new ActiveTask(run.getTaskId(), TaskStatus.valueOf(run.getStatus())));
+ .map(view -> new ActiveTask(view.getTaskId(), TaskStatus.valueOf(view.getStatus())));
}
/** 대화의 현재 살아있는 태스크 포인터(id + 상태). */
diff --git a/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java b/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java
index cb111880..86904f74 100644
--- a/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java
+++ b/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java
@@ -3,6 +3,7 @@
import com.example.dvely.approval.application.command.ApprovalCommandService;
import com.example.dvely.approval.application.query.ApprovalQueryService;
import com.example.dvely.approval.application.result.ApprovalResult;
+import com.example.dvely.common.paging.CursorPage;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -14,8 +15,11 @@ public class ApprovalFacade {
private final ApprovalQueryService queryService;
private final ApprovalCommandService commandService;
- public List getProjectApprovals(Long ownerUserId, Long projectId) {
- return queryService.getProjectApprovals(ownerUserId, projectId);
+ public CursorPage getProjectApprovals(Long ownerUserId,
+ Long projectId,
+ Integer limit,
+ String after) {
+ return queryService.getProjectApprovals(ownerUserId, projectId, limit, after);
}
public ApprovalResult getApproval(Long ownerUserId, Long approvalId) {
diff --git a/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java b/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java
index d385bed9..1eadd70d 100644
--- a/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java
+++ b/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java
@@ -8,6 +8,8 @@
import com.example.dvely.approval.domain.model.Approval;
import com.example.dvely.approval.domain.repository.ApprovalRepository;
import com.example.dvely.common.exception.NotFoundException;
+import com.example.dvely.common.paging.CursorPage;
+import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.project.domain.exception.ProjectNotFoundException;
import com.example.dvely.project.domain.repository.ProjectRepository;
import java.util.List;
@@ -23,14 +25,30 @@ public class ApprovalQueryService {
private final ApprovalRepository approvalRepository;
private final ProjectRepository projectRepository;
+ /**
+ * limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 승인은 사용자 요청 1회당 0~2건이라
+ * 200 이면 최근 100회 이상의 작업을 덮는다. 활동로그는 최신순으로 합쳐 보여주는 화면이라
+ * 잘리는 쪽은 맨 아래다.
+ */
+ private static final int DEFAULT_APPROVAL_LIMIT = 200;
+ private static final int MAX_APPROVAL_LIMIT = 500;
+
public List getProjectApprovals(Long ownerUserId, Long projectId) {
+ return getProjectApprovals(ownerUserId, projectId, null, null).items();
+ }
+
+ /** U6(#341) 6-4: 상한 + 커서. 최신순이라 {@code after} 는 "그 승인보다 오래된 것" 을 뜻한다. */
+ public CursorPage getProjectApprovals(Long ownerUserId,
+ Long projectId,
+ Integer limit,
+ String after) {
projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, ownerUserId)
.orElseThrow(() -> new ProjectNotFoundException(projectId, ownerUserId));
- return approvalRepository
- .findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId)
- .stream()
- .map(this::toResult)
- .toList();
+ int size = CursorPaging.clamp(limit, DEFAULT_APPROVAL_LIMIT, MAX_APPROVAL_LIMIT);
+ List probed = approvalRepository.findProjectApprovalsPage(
+ projectId, ownerUserId, CursorPaging.parseCursor(after), size + 1);
+ return CursorPaging.slice(probed, size, approval -> String.valueOf(approval.getId()))
+ .map(this::toResult);
}
public ApprovalResult getApproval(Long ownerUserId, Long approvalId) {
diff --git a/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java b/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java
index b332dc88..0fb1876b 100644
--- a/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java
+++ b/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java
@@ -32,7 +32,11 @@ public interface ApprovalRepository {
*/
Optional findRoutingInfo(Long approvalId, Long ownerUserId);
- List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId);
+ /**
+ * U6(#341) 6-4: 프로젝트 승인 목록 한 페이지. 최신순이고 {@code after}(승인 id, 배타적)보다
+ * 오래된 것만 준다. {@code after} 가 null 이면 처음부터다.
+ */
+ List findProjectApprovalsPage(Long projectId, Long ownerUserId, Long after, int limit);
List findByTaskIdOrderByIdAsc(String taskId);
diff --git a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java
index 759eba2b..2e9f8c75 100644
--- a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java
@@ -41,8 +41,9 @@ public Optional findByIdAndOwnerUserIdForUpdate(Long approvalId, Long
}
@Override
- public List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId) {
- return springDataRepository.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId)
+ public List findProjectApprovalsPage(Long projectId, Long ownerUserId, Long after, int limit) {
+ return springDataRepository.findProjectApprovalsPage(
+ projectId, ownerUserId, after, org.springframework.data.domain.PageRequest.of(0, limit))
.stream()
.map(ApprovalEntity::toDomain)
.toList();
diff --git a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java
index eb293ee6..fbf7e570 100644
--- a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java
+++ b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java
@@ -4,6 +4,7 @@
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
@@ -23,7 +24,23 @@ Optional findByIdAndOwnerUserIdForUpdate(
@Param("approvalId") Long approvalId, @Param("ownerUserId") Long ownerUserId
);
- List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId);
+ // U6(#341) 6-4: 무제한이던 목록에 상한과 커서를 더했다. created_at 이 DATETIME(초) 라 같은 초의
+ // 행 순서가 비결정적인데 커서 페이지네이션에서는 그게 행을 건너뛰거나 두 번 주는 버그가 되므로
+ // id 를 tiebreaker 로 붙였다. 최신순이라 커서는 "이 id 보다 작은 것"(= 더 오래된 것)이다.
+ @Query("""
+ select a
+ from ApprovalEntity a
+ where a.projectId = :projectId
+ and a.ownerUserId = :ownerUserId
+ and (:after is null or a.id < :after)
+ order by a.createdAt desc, a.id desc
+ """)
+ List findProjectApprovalsPage(
+ @Param("projectId") Long projectId,
+ @Param("ownerUserId") Long ownerUserId,
+ @Param("after") Long after,
+ Pageable pageable
+ );
// Backs ApprovalRepository#existsByProjectIdAndTypeAndStatus. Reuses the existing
// idx_approvals_project_id index (V14) — MySQL scans by project_id then filters type/status
diff --git a/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java b/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java
index 25749011..ae90dfc7 100644
--- a/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java
+++ b/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java
@@ -5,15 +5,18 @@
import com.example.dvely.approval.presentation.dto.ApprovalDecisionRequest;
import com.example.dvely.approval.presentation.dto.ApprovalInputResponse;
import com.example.dvely.approval.presentation.dto.ApprovalResponse;
+import com.example.dvely.common.paging.CursorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Approval", description = "Agent 작업 승인 및 거절 API")
@@ -25,17 +28,21 @@ public class ApprovalController {
@Operation(
summary = "프로젝트 승인 목록 조회",
- description = "프로젝트에서 생성된 모든 승인 요청을 상태(PENDING/APPROVED/REJECTED/CANCELLED)와 " +
- "무관하게 최신순으로 조회합니다."
+ description = "프로젝트에서 생성된 승인 요청을 상태(PENDING/APPROVED/REJECTED/CANCELLED)와 " +
+ "무관하게 최신순으로 조회합니다. limit 기본 200, 최대 500(초과 시 500으로 보정). " +
+ "더 오래된 것이 남아 있으면 응답 헤더 X-Qeploy-Next-Cursor 에 다음 커서가 실리며, " +
+ "after 로 넘겨 이어 받습니다."
)
@GetMapping("/api/v1/projects/{projectId}/approvals")
- public List getProjectApprovals(
+ public ResponseEntity> getProjectApprovals(
@AuthenticationPrincipal Long ownerUserId,
- @PathVariable Long projectId
+ @PathVariable Long projectId,
+ @RequestParam(required = false) Integer limit,
+ @RequestParam(required = false) String after
) {
- return approvalFacade.getProjectApprovals(ownerUserId, projectId).stream()
- .map(this::toResponse)
- .toList();
+ return CursorResponse.of(
+ approvalFacade.getProjectApprovals(ownerUserId, projectId, limit, after)
+ .map(this::toResponse));
}
@Operation(
diff --git a/src/main/java/com/example/dvely/change/application/service/ChangeService.java b/src/main/java/com/example/dvely/change/application/service/ChangeService.java
index 4919acd6..53dcb577 100644
--- a/src/main/java/com/example/dvely/change/application/service/ChangeService.java
+++ b/src/main/java/com/example/dvely/change/application/service/ChangeService.java
@@ -7,7 +7,10 @@
import com.example.dvely.change.application.result.ChangeResult;
import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity;
import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository;
+import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository.ChangeSummaryView;
import com.example.dvely.common.exception.NotFoundException;
+import com.example.dvely.common.paging.CursorPage;
+import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.preview.application.result.PreviewSessionInfo;
import com.example.dvely.preview.application.service.PreviewSessionService;
import com.example.dvely.project.domain.exception.ProjectNotFoundException;
@@ -125,14 +128,50 @@ public void markDeployed(String taskId) {
});
}
+ /**
+ * limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 개요/활동로그는 최신순 목록을 그대로
+ * 합쳐 보여주는 화면이라 오래된 꼬리가 잘려도 화면이 달라지지 않는다 — 잘리는 쪽은 활동로그
+ * 맨 아래다. 변경 건은 사용자 요청 1회당 최대 1건씩 쌓이므로 200 이면 최근 200번의 작업을 덮는다.
+ */
+ private static final int DEFAULT_CHANGE_LIMIT = 200;
+ private static final int MAX_CHANGE_LIMIT = 500;
+
@Transactional(readOnly = true)
public List getProjectChanges(Long ownerUserId, Long projectId) {
+ return getProjectChanges(ownerUserId, projectId, null, null).items();
+ }
+
+ @Transactional(readOnly = true)
+ public CursorPage getProjectChanges(Long ownerUserId,
+ Long projectId,
+ Integer limit,
+ String after) {
projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, ownerUserId)
.orElseThrow(() -> new ProjectNotFoundException(projectId, ownerUserId));
- return changeRepository.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId)
- .stream()
- .map(ChangeEntity::toResult)
- .toList();
+ int size = CursorPaging.clamp(limit, DEFAULT_CHANGE_LIMIT, MAX_CHANGE_LIMIT);
+ List probed = changeRepository.findProjectChangeSummaries(
+ projectId, ownerUserId, CursorPaging.parseCursor(after), CursorPaging.probe(size));
+ return CursorPaging.slice(probed, size, view -> String.valueOf(view.getId()))
+ .map(ChangeService::toResult);
+ }
+
+ /** 프로젝션 → ChangeResult. 필드 순서·값은 {@code ChangeEntity#toResult} 와 1:1 이다. */
+ private static ChangeResult toResult(ChangeSummaryView view) {
+ return new ChangeResult(
+ view.getId(),
+ view.getProjectId(),
+ view.getConversationId(),
+ view.getTaskId(),
+ view.getPreviewSessionId(),
+ view.getStatus(),
+ view.getSummary(),
+ view.getApprovalId(),
+ view.getPrNumber(),
+ view.getMergeCommitSha(),
+ view.getMergedAt(),
+ view.getCreatedAt(),
+ view.getUpdatedAt()
+ );
}
@Transactional(readOnly = true)
diff --git a/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java b/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java
index 8d76a880..2a6129a3 100644
--- a/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java
+++ b/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java
@@ -1,10 +1,14 @@
package com.example.dvely.change.infrastructure.persistence.repository;
import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity;
+import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SpringDataChangeRepository extends JpaRepository {
@@ -12,7 +16,53 @@ public interface SpringDataChangeRepository extends JpaRepository findByIdAndOwnerUserId(Long changeId, Long ownerUserId);
- List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId);
+ // U6 6-1: 목록은 프로젝션으로만 읽는다. 엔티티로 읽으면 diff_text(MEDIUMTEXT, 행당 최대 1MB)까지
+ // 함께 실려 오는데 ChangeResult 에는 diff 가 없다 — 프로젝트 개요를 한 번 열면 그 프로젝트의
+ // 모든 diff 를 DB 에서 끌어오고 그대로 버리고 있었다. 아래 뷰의 컬럼 = ChangeResult 의 필드다.
+ interface ChangeSummaryView {
+ Long getId();
+ Long getProjectId();
+ Long getConversationId();
+ String getTaskId();
+ String getPreviewSessionId();
+ String getStatus();
+ String getSummary();
+ Long getApprovalId();
+ Integer getPrNumber();
+ String getMergeCommitSha();
+ LocalDateTime getMergedAt();
+ LocalDateTime getCreatedAt();
+ LocalDateTime getUpdatedAt();
+ }
+
+ // createdAt 뒤에 id 를 덧붙여 정렬한다 — created_at 이 같은 초인 행들의 순서가 비결정적이면
+ // 커서(:after) 페이지네이션이 같은 행을 건너뛰거나 두 번 준다.
+ @Query("""
+ select c.id as id,
+ c.projectId as projectId,
+ c.conversationId as conversationId,
+ c.taskId as taskId,
+ c.previewSessionId as previewSessionId,
+ c.status as status,
+ c.summary as summary,
+ c.approvalId as approvalId,
+ c.prNumber as prNumber,
+ c.mergeCommitSha as mergeCommitSha,
+ c.mergedAt as mergedAt,
+ c.createdAt as createdAt,
+ c.updatedAt as updatedAt
+ from ChangeEntity c
+ where c.projectId = :projectId
+ and c.ownerUserId = :ownerUserId
+ and (:after is null or c.id < :after)
+ order by c.createdAt desc, c.id desc
+ """)
+ List findProjectChangeSummaries(
+ @Param("projectId") Long projectId,
+ @Param("ownerUserId") Long ownerUserId,
+ @Param("after") Long after,
+ Pageable pageable
+ );
// Track Z (#56) review follow-up (BLOCKING-1): backs ResultApprovalService#hasResultGateHistory
// — a project-scoped existence check (any status, not just the current task's own Change row)
diff --git a/src/main/java/com/example/dvely/change/presentation/ChangeController.java b/src/main/java/com/example/dvely/change/presentation/ChangeController.java
index b2d97ca9..f8ae08da 100644
--- a/src/main/java/com/example/dvely/change/presentation/ChangeController.java
+++ b/src/main/java/com/example/dvely/change/presentation/ChangeController.java
@@ -3,13 +3,17 @@
import com.example.dvely.change.application.service.ChangeService;
import com.example.dvely.change.presentation.dto.ChangeDiffResponse;
import com.example.dvely.change.presentation.dto.ChangeResponse;
+import com.example.dvely.common.paging.CursorResponse;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Change", description = "Agent CODE 작업 결과(코드 변경) 조회 API. 변경 상태와 diff를 제공합니다.")
@@ -21,16 +25,21 @@ public class ChangeController {
@Operation(
summary = "프로젝트 Change 목록 조회",
- description = "프로젝트에서 실행된 모든 Agent CODE 작업(코드 변경) 이력을 조회합니다."
+ description = "프로젝트에서 실행된 Agent CODE 작업(코드 변경) 이력을 최신순으로 조회합니다. "
+ + "limit 를 주지 않으면 최신 200건까지 내려가고, 더 있으면 응답 헤더 "
+ + "X-Qeploy-Next-Cursor 에 다음 커서가 실립니다(after 로 넘겨 이어 받습니다)."
)
@GetMapping("/api/v1/projects/{projectId}/changes")
- public List getProjectChanges(
+ public ResponseEntity> getProjectChanges(
@AuthenticationPrincipal Long ownerUserId,
- @PathVariable Long projectId
+ @PathVariable Long projectId,
+ @Parameter(description = "한 번에 받을 최대 건수(1~500). 없으면 200") @RequestParam(required = false) Integer limit,
+ @Parameter(description = "이전 페이지의 X-Qeploy-Next-Cursor 값. 그 항목보다 오래된 건만 반환")
+ @RequestParam(required = false) String after
) {
- return changeService.getProjectChanges(ownerUserId, projectId).stream()
- .map(ChangeResponse::from)
- .toList();
+ return CursorResponse.of(
+ changeService.getProjectChanges(ownerUserId, projectId, limit, after)
+ .map(ChangeResponse::from));
}
@Operation(
diff --git a/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java b/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java
index 5f1bf380..67bb6b0f 100644
--- a/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java
+++ b/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java
@@ -69,38 +69,31 @@ public ConversationResult restoreConversation(Long userId, Long conversationId)
return toResult(conversationRepository.save(conversation), restoreProject, LocalDateTime.now());
}
+ /**
+ * U6(#341) 6-8: 한 문장으로 옮긴다. 예전에는 대화 N 건을 엔티티로 읽어 한 건씩 save 했다 —
+ * 프로젝트 삭제 한 번에 SELECT 1 + UPDATE N 이었다. softDelete 의 "이미 삭제됐으면 무시" 가드는
+ * 쿼리의 {@code deleted = false} 조건이 그대로 대신한다.
+ */
@Transactional
public void trashConversationsForProject(Long userId, Long projectId) {
- List conversations = conversationRepository
- .findAllByUserIdAndProjectIdAndDeletedFalseOrderByUpdatedAtDesc(userId, projectId);
- LocalDateTime deletedAt = LocalDateTime.now();
- for (Conversation conversation : conversations) {
- conversation.softDelete(deletedAt);
- conversationRepository.save(conversation);
- }
+ conversationRepository.softDeleteAllByUserIdAndProjectId(userId, projectId, LocalDateTime.now());
}
+ /**
+ * U6 6-8: 한 문장으로 지운다. 메시지는 따로 지우지 않는다(#338) — chat_messages 의 FK 가 V19
+ * 부터 ON DELETE CASCADE 라 DB 가 함께 지운다. 벌크 DELETE 도 실제 SQL DELETE 이므로 그 CASCADE
+ * 와 다른 테이블의 SET NULL(approvals·agent_runs 등 이력 보존)이 예전과 똑같이 돈다.
+ */
@Transactional
public void deleteConversationsForProject(Long userId, Long projectId) {
- List conversations = conversationRepository.findAllByUserIdAndProjectId(userId, projectId);
- for (Conversation conversation : conversations) {
- if (conversation.getId() == null) {
- continue;
- }
- // 메시지는 따로 지우지 않는다(#338). chat_messages 의 FK 는 V19 부터
- // ON DELETE CASCADE 라 아래 한 줄이 메시지까지 지운다. 앞서 있던
- // deleteAllByConversationId 는 엔티티를 N 건 로드해 한 건씩 지운 뒤 CASCADE 가
- // 같은 일을 또 하는 이중 삭제였다. 바로 아래 purgeExpiredConversations 도
- // 예전부터 deleteById 하나로만 지우고 있었다 — 그쪽이 맞는 쪽이었다.
- conversationRepository.deleteById(conversation.getId());
- }
+ conversationRepository.deleteAllByUserIdAndProjectId(userId, projectId);
}
/**
* 만료된 휴지통 대화를 영구 삭제한다.
*
- *
#340 5-9: 엔티티를 전부 로드한 뒤 {@code deleteById} 를 N 번 부르던 것을 벌크 DELETE
- * 한 문장으로 바꿨다. 지우려고 읽을 이유가 없다 — 삭제 조건이 곧 SELECT 조건이었다.
+ *
#340 5-9 · #341 6-8: 엔티티를 전부 로드한 뒤 {@code deleteById} 를 N 번 부르던 것을 벌크
+ * DELETE 한 문장으로 바꿨다. 지우려고 읽을 이유가 없다 — 삭제 조건이 곧 SELECT 조건이었다.
*/
@Transactional
public int purgeExpiredConversations() {
diff --git a/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java b/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java
index 5bd8ec23..d2f3931a 100644
--- a/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java
+++ b/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java
@@ -5,6 +5,7 @@
import com.example.dvely.chat.application.query.ChatQueryService;
import com.example.dvely.chat.application.result.ConversationResult;
import com.example.dvely.chat.application.result.MessageResult;
+import com.example.dvely.common.paging.CursorPage;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -48,7 +49,10 @@ public MessageResult sendMessage(Long userId, Long conversationId, String conten
return chatCommandService.sendMessage(userId, conversationId, content, aiProvider);
}
- public List getMessages(Long userId, Long conversationId) {
- return chatQueryService.getMessages(userId, conversationId);
+ public CursorPage getMessages(Long userId,
+ Long conversationId,
+ Integer limit,
+ String after) {
+ return chatQueryService.getMessages(userId, conversationId, limit, after);
}
}
diff --git a/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java b/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java
index 56736d26..41514488 100644
--- a/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java
+++ b/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java
@@ -1,6 +1,8 @@
package com.example.dvely.chat.application.query;
import com.example.dvely.chat.application.result.ConversationResult;
+import com.example.dvely.common.paging.CursorPage;
+import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.chat.application.result.MessageResult;
import com.example.dvely.chat.domain.exception.ConversationNotFoundException;
import com.example.dvely.chat.domain.model.ChatMessage;
@@ -11,8 +13,10 @@
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
import java.time.LocalDateTime;
+import java.util.HashMap;
import java.util.List;
-import java.util.Optional;
+import java.util.Locale;
+import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,23 +50,53 @@ public ConversationResult getConversation(Long userId, Long conversationId) {
);
}
+ /**
+ * U6(#341) 6-7: 표시할 프로젝트를 대화마다 개별 조회하던 N+1 을 걷어냈다. 휴지통 대화 N 건이면
+ * 예전에는 프로젝트 조회가 최대 3N 번 돌았다(활성 조회 → 원본 조회 → 대체 프로젝트 조회).
+ * 이제 프로젝트 id 를 모아 한 번, 대체 프로젝트가 필요한 저장소를 모아 한 번, 총 2번이다.
+ */
public List getTrashConversations(Long userId) {
LocalDateTime now = LocalDateTime.now();
- return conversationRepository.findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId)
+ List conversations = conversationRepository
+ .findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId)
.stream()
.filter(conversation -> !ChatTrashPolicy.isExpired(conversation.getDeletedAt(), now))
- .map(conversation -> toResult(conversation, resolveTrashProject(userId, conversation), now))
+ .toList();
+ Map displays = resolveTrashProjects(userId, conversations);
+ return conversations.stream()
+ .map(conversation -> toResult(conversation, displays.get(conversation.getProjectId()), now))
.toList();
}
+ /**
+ * 메시지 목록의 상한. 사용자 발화 1건마다 어시스턴트 메시지가 함께 쌓이는 구조라(계획 시작·승인
+ * 안내·스텝 진행·결과·배포 결과 등 appendAssistant 호출 지점이 20곳 넘는다) 한 번의 요청이
+ * 대략 5~9행을 만든다. 500 이면 한 대화에서 사용자 턴 55~100회를 덮는다 — 프로젝트 하나의
+ * 작업 세션으로는 넉넉하고, content 가 TEXT 라 한 페이지의 크기도 여기서 묶인다.
+ */
+ private static final int DEFAULT_MESSAGE_LIMIT = 500;
+ private static final int MAX_MESSAGE_LIMIT = 1000;
+
public List getMessages(Long userId, Long conversationId) {
+ return getMessages(userId, conversationId, null, null).items();
+ }
+
+ /**
+ * U6(#341) 6-3: 상한 + 커서. 오름차순(오래된 것부터)이라는 기존 순서를 그대로 두고 상한만 얹었다.
+ * {@code after} 는 직전 페이지의 마지막 message id 로, 그보다 뒤의 메시지를 준다.
+ */
+ public CursorPage getMessages(Long userId,
+ Long conversationId,
+ Integer limit,
+ String after) {
conversationRepository.findByIdAndUserIdAndDeletedFalse(conversationId, userId)
.orElseThrow(() -> new ConversationNotFoundException(conversationId, userId));
- return chatMessageRepository.findAllByConversationIdOrderByCreatedAtAsc(conversationId)
- .stream()
- .map(this::toMessageResult)
- .toList();
+ int size = CursorPaging.clamp(limit, DEFAULT_MESSAGE_LIMIT, MAX_MESSAGE_LIMIT);
+ List probed = chatMessageRepository.findPageByConversationId(
+ conversationId, CursorPaging.parseCursor(after), size + 1);
+ return CursorPaging.slice(probed, size, message -> String.valueOf(message.getId()))
+ .map(this::toMessageResult);
}
private Project resolveActiveProject(Long userId, Long projectId) {
@@ -102,27 +136,73 @@ private MessageResult toMessageResult(ChatMessage message) {
);
}
- private ProjectDisplay resolveTrashProject(Long userId, Conversation conversation) {
- Long projectId = conversation.getProjectId();
- Optional activeProject = projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, userId);
- if (activeProject.isPresent()) {
- Project project = activeProject.get();
- return new ProjectDisplay(project.getId(), project.getName());
+ /**
+ * 대화들이 가리키는 프로젝트를 한꺼번에 풀어 projectId → 표시값 으로 만든다. 판정 순서는
+ * 예전 대화별 로직과 같다: 원본이 살아 있으면 그것, 삭제됐으면 같은 저장소의 활성 프로젝트,
+ * 그것도 없으면 삭제된 원본, 아예 없으면 "삭제된 프로젝트".
+ */
+ private Map resolveTrashProjects(Long userId, List conversations) {
+ List projectIds = conversations.stream()
+ .map(Conversation::getProjectId)
+ .distinct()
+ .toList();
+ if (projectIds.isEmpty()) {
+ return Map.of();
}
- Optional originalProject = projectRepository.findByIdAndOwnerUserId(projectId, userId);
- Optional replacementProject = originalProject
+ // ① 해당 프로젝트들 — 삭제 여부를 가리지 않는다. 예전의 "활성 조회 → 원본 조회" 두 번을
+ // 한 번으로 합친다(isDeleted 로 갈라 쓴다).
+ Map byId = new HashMap<>();
+ for (Project project : projectRepository.findAllByIdInAndOwnerUserId(projectIds, userId)) {
+ byId.put(project.getId(), project);
+ }
+
+ // ② 대체 프로젝트가 필요한 저장소들 — 원본이 삭제됐고 저장소를 갖고 있는 경우만.
+ List repositories = projectIds.stream()
+ .map(byId::get)
+ .filter(project -> project != null && project.isDeleted())
.map(Project::getSourceRepository)
- .filter(sourceRepository -> sourceRepository != null && !sourceRepository.isBlank())
- .flatMap(sourceRepository -> projectRepository
- .findFirstByOwnerUserIdAndSourceRepositoryIgnoreCaseAndDeletedFalseOrderByUpdatedAtDesc(
- userId,
- sourceRepository
- ));
- Project displayProject = replacementProject.orElseGet(() -> originalProject.orElse(null));
- return displayProject == null
- ? new ProjectDisplay(projectId, "삭제된 프로젝트")
- : new ProjectDisplay(displayProject.getId(), displayProject.getName());
+ .filter(repository -> repository != null && !repository.isBlank())
+ .distinct()
+ .toList();
+ // 보정이 필요한 저장소가 없으면 두 번째 조회는 아예 돌지 않는다 — 휴지통 대화의 원본
+ // 프로젝트가 전부 살아 있는 흔한 경우가 여기다.
+ Map replacementByRepository = new HashMap<>();
+ for (Project candidate : repositories.isEmpty()
+ ? List.of()
+ : projectRepository.findAllActiveByOwnerUserIdAndSourceRepositoryIn(userId, repositories)) {
+ // 최신순으로 들어오므로 저장소별 첫 건만 남긴다 — 예전 findFirst...OrderByUpdatedAtDesc
+ // 가 돌려주던 것과 같다. 키를 소문자로 맞추는 것은 컬럼 컬레이션이 대소문자를 구분하지
+ // 않아 DB 가 대소문자 다른 값끼리 매칭해 주기 때문이다.
+ replacementByRepository.putIfAbsent(normalizeRepository(candidate.getSourceRepository()), candidate);
+ }
+
+ Map displays = new HashMap<>();
+ for (Long projectId : projectIds) {
+ displays.put(projectId, resolveDisplay(projectId, byId.get(projectId), replacementByRepository));
+ }
+ return displays;
+ }
+
+ private ProjectDisplay resolveDisplay(Long projectId,
+ Project project,
+ Map replacementByRepository) {
+ if (project == null) {
+ return new ProjectDisplay(projectId, "삭제된 프로젝트");
+ }
+ if (!project.isDeleted()) {
+ return new ProjectDisplay(project.getId(), project.getName());
+ }
+ String repository = project.getSourceRepository();
+ Project replacement = repository == null || repository.isBlank()
+ ? null
+ : replacementByRepository.get(normalizeRepository(repository));
+ Project displayProject = replacement == null ? project : replacement;
+ return new ProjectDisplay(displayProject.getId(), displayProject.getName());
+ }
+
+ private static String normalizeRepository(String repository) {
+ return repository.toLowerCase(Locale.ROOT);
}
private record ProjectDisplay(Long projectId, String projectName) {
diff --git a/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java b/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java
index a02ebd33..f0bd2dd0 100644
--- a/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java
+++ b/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java
@@ -12,6 +12,12 @@ public interface ChatMessageRepository {
*/
List findAllByConversationIdOrderByCreatedAtAsc(Long conversationId);
+ /**
+ * U6(#341) 6-3: 위와 같은 순서로, 다만 {@code after}(message id, 배타적) 이후부터 최대
+ * {@code limit} 건. {@code after} 가 null 이면 처음부터다.
+ */
+ List findPageByConversationId(Long conversationId, Long after, int limit);
+
// deleteAllByConversationId 는 없다(#338). chat_messages 의 FK 는 V19 부터
// ON DELETE CASCADE 라, 대화 행을 지우면 메시지는 DB 가 지운다. 별도 삭제를 두면
// 엔티티를 N 건 로드해 한 건씩 지운 뒤 CASCADE 가 같은 일을 또 하는 이중 삭제가 된다.
diff --git a/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java b/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java
index c0a10e12..3dfae5ea 100644
--- a/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java
+++ b/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java
@@ -9,11 +9,17 @@ public interface ConversationRepository {
List findAllByUserIdAndProjectIdAndDeletedFalseOrderByUpdatedAtDesc(Long userId, Long projectId);
- List findAllByUserIdAndProjectId(Long userId, Long projectId);
-
List findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId);
- List findAllByDeletedTrueAndDeletedAtLessThanEqual(LocalDateTime cutoff);
+ /**
+ * U6(#341) 6-8: 프로젝트의 활성 대화를 전부 휴지통으로. 예전에는 N 건을 읽어 한 건씩 save 했다.
+ * 돌려주는 값은 옮긴 행 수다.
+ */
+ int softDeleteAllByUserIdAndProjectId(Long userId, Long projectId, LocalDateTime deletedAt);
+
+ /** U6 6-8: 프로젝트의 대화를 전부 삭제. 메시지는 FK ON DELETE CASCADE 가 함께 지운다. */
+ int deleteAllByUserIdAndProjectId(Long userId, Long projectId);
+
Optional findByIdAndUserIdAndDeletedFalse(Long conversationId, Long userId);
diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java
index 7a98151e..cbd529c3 100644
--- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java
@@ -5,6 +5,7 @@
import com.example.dvely.chat.infrastructure.persistence.entity.ChatMessageEntity;
import java.util.List;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
@Repository
@@ -20,6 +21,15 @@ public List findAllByConversationIdOrderByCreatedAtAsc(Long convers
.toList();
}
+ @Override
+ public List findPageByConversationId(Long conversationId, Long after, int limit) {
+ return springDataChatMessageRepository
+ .findPageByConversationId(conversationId, after, PageRequest.of(0, limit))
+ .stream()
+ .map(ChatMessageEntity::toDomain)
+ .toList();
+ }
+
@Override
public ChatMessage save(ChatMessage message) {
ChatMessageEntity entity = ChatMessageEntity.from(message);
diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java
index 252fd9f3..c92d4748 100644
--- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java
@@ -8,6 +8,7 @@
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
@Repository
@RequiredArgsConstructor
@@ -23,22 +24,21 @@ public List findAllByUserIdAndProjectIdAndDeletedFalseOrderByUpdat
}
@Override
- public List findAllByUserIdAndProjectId(Long userId, Long projectId) {
- return springDataConversationRepository.findByUserIdAndProjectId(userId, projectId).stream()
- .map(ConversationEntity::toDomain)
- .toList();
+ @Transactional
+ public int softDeleteAllByUserIdAndProjectId(Long userId, Long projectId, LocalDateTime deletedAt) {
+ return springDataConversationRepository.softDeleteByUserIdAndProjectId(userId, projectId, deletedAt);
}
@Override
- public List findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId) {
- return springDataConversationRepository.findByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId).stream()
- .map(ConversationEntity::toDomain)
- .toList();
+ @Transactional
+ public int deleteAllByUserIdAndProjectId(Long userId, Long projectId) {
+ return springDataConversationRepository.deleteByUserIdAndProjectId(userId, projectId);
}
+
@Override
- public List findAllByDeletedTrueAndDeletedAtLessThanEqual(LocalDateTime cutoff) {
- return springDataConversationRepository.findByDeletedTrueAndDeletedAtLessThanEqual(cutoff).stream()
+ public List findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId) {
+ return springDataConversationRepository.findByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId).stream()
.map(ConversationEntity::toDomain)
.toList();
}
diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java
index 138eb8bd..ba695169 100644
--- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java
+++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java
@@ -2,7 +2,10 @@
import com.example.dvely.chat.infrastructure.persistence.entity.ChatMessageEntity;
import java.util.List;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SpringDataChatMessageRepository extends JpaRepository {
@@ -17,4 +20,24 @@ public interface SpringDataChatMessageRepository extends JpaRepository
*/
List findByConversationIdOrderByIdAsc(Long conversationId);
+
+ /**
+ * U6(#341) 6-3: 위와 같은 정렬(message_id asc — 그 근거는 위 javadoc)에 상한과 커서를 더한 것.
+ * {@code after} 가 null 이면 처음부터다.
+ *
+ *
커서를 message_id 로 잡은 이유도 같다: created_at 은 DATETIME(0) 이라 같은 초의 행 순서가
+ * 비결정적인데, 커서 페이지네이션에서 그건 행을 건너뛰거나 두 번 주는 버그가 된다.
+ */
+ @Query("""
+ select m
+ from ChatMessageEntity m
+ where m.conversationId = :conversationId
+ and (:after is null or m.id > :after)
+ order by m.id asc
+ """)
+ List findPageByConversationId(
+ @Param("conversationId") Long conversationId,
+ @Param("after") Long after,
+ Pageable pageable
+ );
}
diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java
index d5e948a2..e34f290f 100644
--- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java
+++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java
@@ -13,12 +13,8 @@ public interface SpringDataConversationRepository extends JpaRepository findByUserIdAndProjectIdAndDeletedFalseOrderByUpdatedAtDesc(Long userId, Long projectId);
- List findByUserIdAndProjectId(Long userId, Long projectId);
-
List findByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId);
- List findByDeletedTrueAndDeletedAtLessThanEqual(LocalDateTime cutoff);
-
Optional findByIdAndUserIdAndDeletedFalse(Long conversationId, Long userId);
Optional findByIdAndUserId(Long conversationId, Long userId);
@@ -34,4 +30,42 @@ public interface SpringDataConversationRepository extends JpaRepository{@code updated_at} 은 그대로 갱신된다 — {@code chat_sessions.updated_at} 컬럼이
+ * {@code ON UPDATE CURRENT_TIMESTAMP} 라 벌크 UPDATE 에서도 DB 가 채운다(@UpdateTimestamp 는
+ * 벌크 문장에서 돌지 않는다). 휴지통 목록이 updated_at 순이므로 이게 유지돼야 순서가 같다.
+ *
+ *
{@code clearAutomatically} 를 켜지 않는다. 영속성 컨텍스트를 비우면 같은 트랜잭션의
+ * {@code ProjectRepositoryAdapter#save} 가 L1 캐시 히트에 기대는 낙관적 잠금 경로(그쪽 javadoc
+ * 의 Case A)를 잃는다 — 이 메서드는 대화를 읽지 않으므로 비울 이유도 없다.
+ */
+ @Modifying(flushAutomatically = true)
+ @Query("""
+ update ConversationEntity c
+ set c.deleted = true,
+ c.deletedAt = :deletedAt
+ where c.userId = :userId
+ and c.projectId = :projectId
+ and c.deleted = false
+ """)
+ int softDeleteByUserIdAndProjectId(
+ @Param("userId") Long userId,
+ @Param("projectId") Long projectId,
+ @Param("deletedAt") LocalDateTime deletedAt
+ );
+
+ /**
+ * U6 6-8: 프로젝트의 대화를 한 문장으로 지운다. 메시지는 DB 가 지운다 — chat_messages 의 FK 가
+ * V19 부터 ON DELETE CASCADE 다(#338). 실제 SQL DELETE 이므로 벌크여도 CASCADE·SET NULL 이
+ * 그대로 돈다(approvals·agent_runs 등은 SET NULL 로 이력을 보존한다).
+ */
+ @Modifying(flushAutomatically = true)
+ @Query("delete from ConversationEntity c where c.userId = :userId and c.projectId = :projectId")
+ int deleteByUserIdAndProjectId(@Param("userId") Long userId, @Param("projectId") Long projectId);
+
+ // 보관 기간이 지난 휴지통 대화 삭제는 위 deleteExpiredTrash 하나로 충분하다(#340 에서 먼저 들어왔다).
}
diff --git a/src/main/java/com/example/dvely/chat/presentation/ChatController.java b/src/main/java/com/example/dvely/chat/presentation/ChatController.java
index a23b30a7..2a8dd6e5 100644
--- a/src/main/java/com/example/dvely/chat/presentation/ChatController.java
+++ b/src/main/java/com/example/dvely/chat/presentation/ChatController.java
@@ -5,6 +5,7 @@
import com.example.dvely.chat.presentation.dto.ConversationResponse;
import com.example.dvely.chat.presentation.dto.MessageResponse;
import com.example.dvely.chat.presentation.dto.SendMessageRequest;
+import com.example.dvely.common.paging.CursorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,12 +13,14 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -126,17 +129,24 @@ public void permanentlyDeleteConversation(
@Operation(
summary = "대화 메시지 목록 조회",
- description = "대화 세션에 저장된 메시지를 생성순으로 조회합니다. " +
- "삭제되지 않은 현재 유저 소유 대화에 대해서만 메시지를 반환합니다."
+ description = "대화 세션에 저장된 메시지를 생성순(오래된 것부터)으로 조회합니다. " +
+ "삭제되지 않은 현재 유저 소유 대화에 대해서만 메시지를 반환합니다. " +
+ "limit 기본 500, 최대 1000(초과 시 1000으로 보정). 더 남아 있으면 응답 헤더 " +
+ "X-Qeploy-Next-Cursor 에 다음 커서가 실리며, after 로 넘겨 이어 받습니다. " +
+ "헤더가 없으면 마지막 페이지입니다."
)
@GetMapping("/api/v1/conversations/{conversationId}/messages")
- public List getMessages(
+ public ResponseEntity> getMessages(
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
- @Parameter(description = "메시지 목록을 조회할 대화 ID") @PathVariable Long conversationId
+ @Parameter(description = "메시지 목록을 조회할 대화 ID") @PathVariable Long conversationId,
+ @Parameter(description = "한 번에 받을 최대 건수(1~1000). 없으면 500")
+ @RequestParam(required = false) Integer limit,
+ @Parameter(description = "이전 페이지의 X-Qeploy-Next-Cursor 값. 그 메시지보다 뒤의 것만 반환")
+ @RequestParam(required = false) String after
) {
- return chatFacade.getMessages(userId, conversationId).stream()
- .map(chatMapper::toMessageResponse)
- .toList();
+ return CursorResponse.of(
+ chatFacade.getMessages(userId, conversationId, limit, after)
+ .map(chatMapper::toMessageResponse));
}
@Operation(
diff --git a/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java b/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java
index 69c95f9f..c666464f 100644
--- a/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java
+++ b/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java
@@ -6,6 +6,9 @@
import com.example.dvely.cloudconnection.domain.model.CloudConnection;
import com.example.dvely.cloudconnection.domain.model.CloudConnectionVerificationJob;
import com.example.dvely.cloudconnection.domain.repository.CloudConnectionRepository;
+import com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView;
+import com.example.dvely.cloudconnection.domain.value.CloudConnectionStatus;
+import com.example.dvely.cloudconnection.domain.value.CloudProvider;
import com.example.dvely.cloudconnection.domain.repository.CloudConnectionVerificationJobRepository;
import com.example.dvely.common.exception.NotFoundException;
import java.util.List;
@@ -22,11 +25,38 @@ public class CloudConnectionQueryService {
private final CloudConnectionVerificationJobRepository verificationJobRepository;
public List getCloudConnections(Long ownerUserId) {
- return cloudConnectionRepository.findAllByOwnerUserIdOrderByCreatedAtDesc(ownerUserId).stream()
- .map(this::toResult)
+ return cloudConnectionRepository.findSummariesByOwnerUserIdOrderByCreatedAtDesc(ownerUserId).stream()
+ .map(CloudConnectionQueryService::toResult)
.toList();
}
+ /**
+ * 읽기 모델 → 응답. 비밀 세 개는 뷰가 이미 boolean 으로만 들고 있어, 여기서 평문을 볼 방법이
+ * 없다 — 예전의 "평문을 읽어 != null 로 바꾼 뒤 버린다" 를 구조적으로 대체한다.
+ */
+ private static CloudConnectionResult toResult(CloudConnectionSummaryView view) {
+ return new CloudConnectionResult(
+ view.id(),
+ CloudProvider.valueOf(view.provider()),
+ view.displayName(),
+ view.accountId(),
+ view.region(),
+ view.roleArn(),
+ view.awsCredentialType(),
+ view.accessKeyId(),
+ view.secretAccessKeyConfigured(),
+ view.sessionTokenConfigured(),
+ view.gcpCredentialType(),
+ view.serviceAccountKeyConfigured(),
+ view.gcpProjectId(),
+ view.serviceAccountEmail(),
+ CloudConnectionStatus.valueOf(view.status()),
+ view.lastCheckedAt(),
+ view.createdAt(),
+ view.updatedAt()
+ );
+ }
+
public CloudConnectionResult getCloudConnection(Long ownerUserId, Long cloudConnectionId) {
return toResult(resolveCloudConnection(ownerUserId, cloudConnectionId));
}
diff --git a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java
index 6bb85abd..ef5f7952 100644
--- a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java
+++ b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java
@@ -11,6 +11,9 @@ public interface CloudConnectionRepository {
List findAllByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
+ /** U6 6-5: 목록 조회 전용. 비밀 컬럼을 읽지 않는다 — {@link CloudConnectionSummaryView} 참고. */
+ List findSummariesByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
+
/** 특정 프로바이더의 모든 연결(소유자 무관). 고아 자원 스윕이 전 계정을 훑을 때 쓴다(예: AWS=CloudFront). */
List findAllByProvider(CloudProvider provider);
diff --git a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java
new file mode 100644
index 00000000..991bf8d7
--- /dev/null
+++ b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java
@@ -0,0 +1,36 @@
+package com.example.dvely.cloudconnection.domain.repository;
+
+import java.time.LocalDateTime;
+
+/**
+ * 클라우드 연결 목록 전용 읽기 모델. U6(#341) 6-5.
+ *
+ *
비밀 값이 이 레코드에 들어올 자리는 없다. {@code secret_access_key}·{@code session_token}·
+ * {@code service_account_key_json} 은 MEDIUMTEXT 이고 {@code @Convert(AesEncryptor)} 가 붙어 있어
+ * 엔티티로 읽으면 행마다 AES 복호화가 돈다. 그런데 목록 응답이 쓰는 것은 "설정돼 있는가"
+ * 세 개의 boolean 뿐이다 — 그래서 쿼리가 {@code is not null} 만 묻고 값 자체를 읽지 않는다.
+ *
+ *
평문이 응답에서 걸러지는 것에 의존하던 예전 구조보다 강하다: 평문이 애초에 메모리에
+ * 올라오지 않는다. 여기에 비밀 컬럼을 담는 필드를 추가하지 말 것 — 그 순간 유출 경로가 생긴다.
+ */
+public record CloudConnectionSummaryView(
+ Long id,
+ String provider,
+ String displayName,
+ String accountId,
+ String region,
+ String roleArn,
+ String awsCredentialType,
+ String accessKeyId,
+ boolean secretAccessKeyConfigured,
+ boolean sessionTokenConfigured,
+ String gcpCredentialType,
+ boolean serviceAccountKeyConfigured,
+ String gcpProjectId,
+ String serviceAccountEmail,
+ String status,
+ LocalDateTime lastCheckedAt,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java
index d232e5ad..65f93927 100644
--- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java
@@ -2,6 +2,7 @@
import com.example.dvely.cloudconnection.domain.model.CloudConnection;
import com.example.dvely.cloudconnection.domain.repository.CloudConnectionRepository;
+import com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView;
import com.example.dvely.cloudconnection.infrastructure.persistence.entity.CloudConnectionEntity;
import java.util.List;
import java.util.Optional;
@@ -40,6 +41,11 @@ public List findAllByOwnerUserIdOrderByCreatedAtDesc(Long owner
.toList();
}
+ @Override
+ public List findSummariesByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId) {
+ return springDataRepository.findSummariesByOwnerUserId(ownerUserId);
+ }
+
@Override
public Optional findByIdAndOwnerUserId(Long id, Long ownerUserId) {
return springDataRepository.findByIdAndOwnerUserId(id, ownerUserId)
diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java
index f7274688..120f850a 100644
--- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java
+++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java
@@ -1,14 +1,36 @@
package com.example.dvely.cloudconnection.infrastructure.persistence.repository;
+import com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView;
import com.example.dvely.cloudconnection.infrastructure.persistence.entity.CloudConnectionEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SpringDataCloudConnectionRepository extends JpaRepository {
List findAllByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
+ // U6 6-5: 목록은 비밀 컬럼을 읽지 않는다. 세 개의 MEDIUMTEXT 는 is not null 로만 묻는다 —
+ // 엔티티로 읽으면 @Convert(AesEncryptor) 가 행마다 복호화를 돌리는데 응답에는 boolean 세 개만
+ // 나간다. is not null 은 값이 아니라 널 여부만 보므로 off-page TEXT 본문을 읽지 않는다.
+ @Query("""
+ select new com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView(
+ c.id, c.provider, c.displayName, c.accountId, c.region, c.roleArn,
+ c.awsCredentialType, c.accessKeyId,
+ case when c.secretAccessKey is not null then true else false end,
+ case when c.sessionToken is not null then true else false end,
+ c.gcpCredentialType,
+ case when c.serviceAccountKeyJson is not null then true else false end,
+ c.gcpProjectId, c.serviceAccountEmail, c.status, c.lastCheckedAt,
+ c.createdAt, c.updatedAt)
+ from CloudConnectionEntity c
+ where c.ownerUserId = :ownerUserId
+ order by c.createdAt desc, c.id desc
+ """)
+ List findSummariesByOwnerUserId(@Param("ownerUserId") Long ownerUserId);
+
// provider 는 String 컬럼(CloudConnectionEntity:35).
List findAllByProvider(String provider);
diff --git a/src/main/java/com/example/dvely/common/paging/CursorPage.java b/src/main/java/com/example/dvely/common/paging/CursorPage.java
new file mode 100644
index 00000000..3ca0054f
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/paging/CursorPage.java
@@ -0,0 +1,25 @@
+package com.example.dvely.common.paging;
+
+import java.util.List;
+
+/**
+ * 커서 페이지네이션 한 페이지. U6(#341) 6-3·6-4 에서 무제한 목록 조회에 상한을 두기 위해 도입했다.
+ *
+ *
응답 본문 모양은 바꾸지 않는다. 기존 목록 엔드포인트는 전부 JSON 배열을 그대로 내보내므로
+ * 여기에 커서를 끼워 넣으면 FE 가 즉시 깨진다. 그래서 컨트롤러는 {@link #items()} 만 본문으로 내고
+ * {@link #nextCursor()} 는 응답 헤더({@code X-Qeploy-Next-Cursor})로 싣는다 — 커서를 쓰지 않는
+ * 기존 FE 는 헤더를 무시하면 예전과 똑같이 동작한다.
+ *
+ * @param items 이 페이지의 항목. 요청한(또는 기본) 상한 이하다
+ * @param nextCursor 다음 페이지의 {@code after} 로 넘길 값. 더 볼 것이 없으면 null
+ */
+public record CursorPage(List items, String nextCursor) {
+
+ public static CursorPage of(List items) {
+ return new CursorPage<>(items, null);
+ }
+
+ public CursorPage map(java.util.function.Function mapper) {
+ return new CursorPage<>(items.stream().map(mapper).toList(), nextCursor);
+ }
+}
diff --git a/src/main/java/com/example/dvely/common/paging/CursorPaging.java b/src/main/java/com/example/dvely/common/paging/CursorPaging.java
new file mode 100644
index 00000000..6c248119
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/paging/CursorPaging.java
@@ -0,0 +1,62 @@
+package com.example.dvely.common.paging;
+
+import java.util.List;
+import java.util.function.Function;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+/**
+ * 커서 페이지네이션 공통 규칙. U6(#341) 6-3·6-4.
+ *
+ *
"더 있는지"를 별도 count 쿼리로 묻지 않는다 — {@code limit + 1} 건을 읽어 한 건이 남으면 더
+ * 있는 것이다. count 는 같은 조건을 두 번 훑는 비용인데, 이 작업의 목적 자체가 읽는 양을 줄이는 것이다.
+ */
+public final class CursorPaging {
+
+ private CursorPaging() {
+ }
+
+ /** {@code limit + 1} 건을 읽기 위한 Pageable. */
+ public static Pageable probe(int limit) {
+ return PageRequest.of(0, limit + 1);
+ }
+
+ /**
+ * {@code limit + 1} 건 읽어온 결과를 한 페이지로 자른다. 넘치면 마지막으로 살아남은 항목에서
+ * 커서를 뽑아 싣는다.
+ */
+ public static CursorPage slice(List probed, int limit, Function cursorOf) {
+ if (probed.size() <= limit) {
+ return CursorPage.of(probed);
+ }
+ List page = probed.subList(0, limit);
+ return new CursorPage<>(List.copyOf(page), cursorOf.apply(page.get(limit - 1)));
+ }
+
+ /**
+ * 요청된 limit 를 [1, max] 로 자른다. 없거나 0 이하면 기본값. 기존 FE 는 limit 를 보내지 않으므로
+ * 이 기본값이 곧 "예전 무제한 동작의 상한"이다.
+ */
+ public static int clamp(Integer requested, int defaultLimit, int maxLimit) {
+ if (requested == null || requested <= 0) {
+ return defaultLimit;
+ }
+ return Math.min(requested, maxLimit);
+ }
+
+ /**
+ * 커서를 id 로 되돌린다. 커서는 우리가 직접 만들어 내보낸 값이라 정상 흐름에서는 늘 숫자다 —
+ * 숫자가 아니면 클라이언트가 손댄 것이므로 400 으로 끊는다(조용히 첫 페이지로 되돌리면
+ * 클라이언트는 자기 루프가 끝나지 않는 이유를 알 수 없다).
+ */
+ public static Long parseCursor(String after) {
+ if (after == null || after.isBlank()) {
+ return null;
+ }
+ try {
+ return Long.parseLong(after.trim());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("잘못된 커서입니다: " + after);
+ }
+ }
+}
diff --git a/src/main/java/com/example/dvely/common/paging/CursorResponse.java b/src/main/java/com/example/dvely/common/paging/CursorResponse.java
new file mode 100644
index 00000000..0584c7a3
--- /dev/null
+++ b/src/main/java/com/example/dvely/common/paging/CursorResponse.java
@@ -0,0 +1,29 @@
+package com.example.dvely.common.paging;
+
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+
+/**
+ * {@link CursorPage} 를 HTTP 응답으로 내보내는 한 곳. U6(#341) 6-3·6-4.
+ *
+ *
본문은 예전과 똑같은 JSON 배열이다. 커서는 본문에 넣지 않고 {@value #NEXT_CURSOR_HEADER}
+ * 헤더로만 싣는다 — 배열을 객체로 감싸는 순간 기존 FE 가 전부 깨지기 때문이다. 커서를 모르는
+ * 클라이언트는 헤더를 무시하면 되고, 그때 보이는 것은 "최신 상한 건수"다.
+ */
+public final class CursorResponse {
+
+ /** 다음 페이지 커서. 더 볼 것이 없으면 헤더 자체가 없다(빈 문자열이 아니다). */
+ public static final String NEXT_CURSOR_HEADER = "X-Qeploy-Next-Cursor";
+
+ private CursorResponse() {
+ }
+
+ public static ResponseEntity> of(CursorPage page) {
+ if (page.nextCursor() == null) {
+ return ResponseEntity.ok(page.items());
+ }
+ return ResponseEntity.ok()
+ .header(NEXT_CURSOR_HEADER, page.nextCursor())
+ .body(page.items());
+ }
+}
diff --git a/src/main/java/com/example/dvely/config/SecurityConfig.java b/src/main/java/com/example/dvely/config/SecurityConfig.java
index d2a1dc6d..27be7a82 100644
--- a/src/main/java/com/example/dvely/config/SecurityConfig.java
+++ b/src/main/java/com/example/dvely/config/SecurityConfig.java
@@ -5,6 +5,7 @@
import com.example.dvely.auth.application.port.out.TokenPort;
import com.example.dvely.apitoken.application.service.ApiTokenAuthenticator;
import com.example.dvely.auth.infrastructure.config.security.JwtAuthenticationFilter;
+import com.example.dvely.common.paging.CursorResponse;
import com.example.dvely.common.response.ApiResponse;
import com.example.dvely.common.response.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -115,6 +116,10 @@ public CorsConfigurationSource corsConfigurationSource(CorsProperties corsProper
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
+ // U6(#341) 6-3·6-4: 커서 페이지네이션의 다음 커서. 브라우저는 노출 목록에 없는 응답 헤더를
+ // JS 에 아예 보여주지 않으므로(allowedHeaders 는 요청 헤더 쪽이다) 여기 없으면 FE 가 커서를
+ // 읽을 수 없다. 목록 응답 본문은 배열 그대로 두고 커서만 헤더로 내보내기 때문에 필요하다.
+ config.setExposedHeaders(List.of(CursorResponse.NEXT_CURSOR_HEADER));
// 프리뷰 게이트웨이 전용 CORS (Issue #108). CSP sandbox(#102)로 불투명 오리진이 된
// 프리뷰 문서의 module script 는 Origin: null 로 오는데, 위 FE 오리진 목록 기반
diff --git a/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java b/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java
index 8324ef73..28aab0ab 100644
--- a/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java
+++ b/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java
@@ -11,7 +11,9 @@
import com.example.dvely.deployment.application.result.VersionDetailResult;
import com.example.dvely.deployment.application.result.VersionResult;
import com.example.dvely.deployment.domain.model.DeploymentHistory;
+import com.example.dvely.deployment.domain.repository.DeploymentHistoryListView;
import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository;
+import com.example.dvely.deployment.domain.repository.DeploymentVersionView;
import com.example.dvely.deployment.infrastructure.workflow.DeployWorkflowTemplate;
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
@@ -107,54 +109,60 @@ private DeploymentStatusResult toStatusResult(DeploymentHistory h, WorkflowRunSt
@Transactional(readOnly = true)
public List getDeploymentHistories(Long ownerUserId, Long projectId) {
findOwnedProject(ownerUserId, projectId);
- return deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(projectId)
+ return deploymentHistoryRepository.findHistoryListViews(projectId)
.stream()
- .map(this::toResult)
+ .map(DeploymentQueryService::toResult)
.toList();
}
- private DeploymentHistoryResult toResult(DeploymentHistory h) {
+ /**
+ * 읽기 모델 → 응답. 상태·타깃·실패코드는 DB 에 문자열로 들어 있고 응답에도 문자열로 나가므로
+ * 중간에 enum 으로 되돌리지 않는다 — 값은 예전과 같고 변환 한 왕복이 사라진다.
+ */
+ private static DeploymentHistoryResult toResult(DeploymentHistoryListView h) {
return new DeploymentHistoryResult(
- h.getId(),
- h.getProjectId(),
- h.getDeployTargetType().name(),
- h.getVersionLabel(),
- h.getDeployedUrl(),
- h.getStatus().name(),
- h.getFailureCode() == null ? null : h.getFailureCode().name(),
- h.getErrorMessage(),
- h.getTriggeredAt(),
- h.getUpdatedAt(),
- h.getRetriedFromHistoryId()
+ h.id(),
+ h.projectId(),
+ h.deployTargetType(),
+ h.versionLabel(),
+ h.deployedUrl(),
+ h.status(),
+ h.failureCode(),
+ h.errorMessage(),
+ h.triggeredAt(),
+ h.updatedAt(),
+ h.retriedFromHistoryId()
);
}
@Transactional(readOnly = true)
public List getVersions(Long ownerUserId, Long projectId) {
findOwnedProject(ownerUserId, projectId);
- List histories = deploymentHistoryRepository
- .findByProjectIdOrderByTriggeredAtDesc(projectId);
+ // "versionLabel 이 있는 것만" 은 이제 SQL 이 거른다. 버전별 최신 1건 추리기는 그대로 메모리에
+ // 남긴다 — 이미 좁아진 행 위의 Map 한 번이고, SQL 로 옮기면 동작이 미묘하게 달라질 수 있다.
+ return latestPerVersionLabel(deploymentHistoryRepository.findLabeledVersionViews(projectId))
+ .map(h -> new VersionResult(
+ h.id(),
+ h.versionLabel(),
+ h.commitSha(),
+ h.title(),
+ h.status(),
+ h.mergedAt() == null ? h.triggeredAt() : h.mergedAt()
+ ))
+ .toList();
+ }
- // versionLabel 기준으로 그룹화 후 각 버전의 최신 이력만 추출 (null 제외)
- Map latestByVersion = histories.stream()
- .filter(h -> h.getVersionLabel() != null && !h.getVersionLabel().isBlank())
+ /** 최신순으로 들어온 목록에서 version_label 별 첫 건만 남기고 다시 최신순으로 돌려준다. */
+ private static java.util.stream.Stream latestPerVersionLabel(
+ List views) {
+ Map latestByVersion = views.stream()
.collect(Collectors.toMap(
- DeploymentHistory::getVersionLabel,
- h -> h,
+ DeploymentVersionView::versionLabel,
+ view -> view,
(existing, replacement) -> existing // 이미 최신순 정렬이므로 첫 번째 유지
));
-
return latestByVersion.values().stream()
- .sorted(Comparator.comparing(DeploymentHistory::getTriggeredAt).reversed())
- .map(h -> new VersionResult(
- h.getId(),
- h.getVersionLabel(),
- h.getCommitSha(),
- h.getTitle(),
- h.getStatus().name(),
- h.getMergedAt() == null ? h.getTriggeredAt() : h.getMergedAt()
- ))
- .toList();
+ .sorted(Comparator.comparing(DeploymentVersionView::triggeredAt).reversed());
}
@Transactional(readOnly = true)
@@ -181,24 +189,17 @@ public VersionDetailResult getVersionDetail(Long ownerUserId, Long versionId) {
@Transactional(readOnly = true)
public List getDeploymentCandidates(Long ownerUserId, Long projectId) {
findOwnedProject(ownerUserId, projectId);
- return deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(projectId).stream()
- .filter(h -> h.getVersionLabel() != null && !h.getVersionLabel().isBlank())
- .filter(h -> h.getStatus() == DeployStatus.LIVE)
- .collect(Collectors.toMap(
- DeploymentHistory::getVersionLabel,
- h -> h,
- (existing, replacement) -> existing
- ))
- .values().stream()
- .sorted(Comparator.comparing(DeploymentHistory::getTriggeredAt).reversed())
+ // LIVE + versionLabel 두 조건 모두 SQL 로 내렸다. 예전에는 프로젝트의 전체 이력을 엔티티로
+ // 읽어와 둘 다 메모리에서 걸렀다.
+ return latestPerVersionLabel(deploymentHistoryRepository.findLiveLabeledVersionViews(projectId))
.map(h -> new DeploymentCandidateResult(
- h.getId(),
- h.getVersionLabel(),
- h.getCommitSha(),
- h.getTitle(),
- h.getStatus().name(),
- h.getDeployedUrl(),
- h.getUpdatedAt()
+ h.id(),
+ h.versionLabel(),
+ h.commitSha(),
+ h.title(),
+ h.status(),
+ h.deployedUrl(),
+ h.updatedAt()
))
.toList();
}
diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java
new file mode 100644
index 00000000..b3a8656b
--- /dev/null
+++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java
@@ -0,0 +1,25 @@
+package com.example.dvely.deployment.domain.repository;
+
+import java.time.LocalDateTime;
+
+/**
+ * 배포 이력 목록 전용 읽기 모델. U6(#341) 6-2.
+ *
+ *
여기 있는 컬럼이 {@code DeploymentHistoryResult} 가 쓰는 전부다. 예전에는 이력 목록이 엔티티를
+ * 통째로(28컬럼, TEXT 2개) 읽은 뒤 절반 넘게 버렸다 — 특히 {@code description TEXT} 는 목록 응답에
+ * 없는데도 매 행 실려 왔다. 필드를 늘리기 전에 정말 응답에 나가는 값인지 확인할 것.
+ */
+public record DeploymentHistoryListView(
+ Long id,
+ Long projectId,
+ String deployTargetType,
+ String versionLabel,
+ String deployedUrl,
+ String status,
+ String failureCode,
+ String errorMessage,
+ LocalDateTime triggeredAt,
+ LocalDateTime updatedAt,
+ Long retriedFromHistoryId
+) {
+}
diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java
index ba372c96..a1ca36b4 100644
--- a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java
+++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java
@@ -13,7 +13,20 @@ public interface DeploymentHistoryRepository {
Optional findById(Long id);
- List findByProjectIdOrderByTriggeredAtDesc(Long projectId);
+ /** U6 6-2: 이력 목록 응답에 실제로 나가는 컬럼만 읽는다. {@link DeploymentHistoryListView} 참고. */
+ List findHistoryListViews(Long projectId);
+
+ /** U6 6-2: version_label 이 있는 이력만. 버전 목록 응답 전용. */
+ List findLabeledVersionViews(Long projectId);
+
+ /** U6 6-2: version_label 이 있고 LIVE 인 이력만. 배포 후보 응답 전용. */
+ List findLiveLabeledVersionViews(Long projectId);
+
+ /**
+ * U6 6-2: 가장 최근 LIVE 이력의 deployedUrl. 값이 null/공백일 수 있고, 그때 다음 LIVE 로 넘어가지
+ * 않는 것이 기존 동작이라 그대로 돌려준다(빈 Optional 과 "빈 값" 을 구분하지 않는다).
+ */
+ Optional findLatestLiveDeployedUrl(Long projectId);
Optional findLatestByProjectId(Long projectId);
diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java
new file mode 100644
index 00000000..877a25c7
--- /dev/null
+++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java
@@ -0,0 +1,23 @@
+package com.example.dvely.deployment.domain.repository;
+
+import java.time.LocalDateTime;
+
+/**
+ * 버전 목록·배포 후보 전용 읽기 모델. U6(#341) 6-2.
+ *
+ *
{@code VersionResult} 와 {@code DeploymentCandidateResult} 가 함께 쓴다 — 두 응답이 쓰는 컬럼의
+ * 합집합이고, 그 둘 말고는 아무것도 담지 않는다. 예전에는 두 조회가 프로젝트의 전체 이력을
+ * 엔티티로 읽어 메모리에서 "version_label 있는 것"·"LIVE 인 것"을 골라냈다. 그 필터는 이제 SQL 에 있다.
+ */
+public record DeploymentVersionView(
+ Long id,
+ String versionLabel,
+ String commitSha,
+ String title,
+ String status,
+ String deployedUrl,
+ LocalDateTime triggeredAt,
+ LocalDateTime mergedAt,
+ LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java
index 1e39f284..cc606e72 100644
--- a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java
@@ -1,9 +1,11 @@
package com.example.dvely.deployment.infrastructure.persistence.repository;
import com.example.dvely.deployment.domain.model.DeploymentHistory;
+import com.example.dvely.deployment.domain.repository.DeploymentHistoryListView;
import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository;
import com.example.dvely.common.worker.WorkQueue;
import com.example.dvely.common.worker.WorkQueuedEvent;
+import com.example.dvely.deployment.domain.repository.DeploymentVersionView;
import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity;
import com.example.dvely.project.domain.value.DeployStatus;
import java.util.Collection;
@@ -53,9 +55,28 @@ public Optional findById(Long id) {
}
@Override
- public List findByProjectIdOrderByTriggeredAtDesc(Long projectId) {
- return springDataRepository.findByProjectIdOrderByTriggeredAtDesc(projectId)
- .stream().map(DeploymentHistoryEntity::toDomain).toList();
+ public List findHistoryListViews(Long projectId) {
+ return springDataRepository.findHistoryListViews(projectId);
+ }
+
+ @Override
+ public List findLabeledVersionViews(Long projectId) {
+ return springDataRepository.findLabeledVersionViews(projectId);
+ }
+
+ @Override
+ public List findLiveLabeledVersionViews(Long projectId) {
+ return springDataRepository.findLiveLabeledVersionViews(projectId, DeployStatus.LIVE.name());
+ }
+
+ @Override
+ public Optional findLatestLiveDeployedUrl(Long projectId) {
+ // deployed_url 은 NULL 일 수 있다. 리스트에 null 원소가 담기므로 Stream#findFirst 를 쓰면
+ // NPE 다 — 그래서 직접 꺼내 ofNullable 로 감싼다. "LIVE 가 없다" 와 "LIVE 인데 URL 이
+ // 비었다" 를 여기서 구분하지 않는 것은 의도다(호출부가 예전처럼 공백까지 판정한다).
+ List urls = springDataRepository.findLiveDeployedUrls(
+ projectId, DeployStatus.LIVE.name(), PageRequest.of(0, 1));
+ return urls.isEmpty() ? Optional.empty() : Optional.ofNullable(urls.get(0));
}
@Override
diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java
index 6689576c..2ffcbd07 100644
--- a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java
+++ b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java
@@ -1,5 +1,7 @@
package com.example.dvely.deployment.infrastructure.persistence.repository;
+import com.example.dvely.deployment.domain.repository.DeploymentHistoryListView;
+import com.example.dvely.deployment.domain.repository.DeploymentVersionView;
import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity;
import java.time.LocalDateTime;
import java.util.Collection;
@@ -13,7 +15,63 @@
public interface SpringDataDeploymentHistoryRepository extends JpaRepository {
- List findByProjectIdOrderByTriggeredAtDesc(Long projectId);
+ // U6 6-2 ①: 이력 목록. 응답에 나가는 11컬럼만 읽는다(엔티티는 28컬럼 + TEXT 2개).
+ // triggeredAt 이 DATETIME(초) 이라 같은 초의 행끼리 순서가 비결정적이었다 — id 를 tiebreaker 로
+ // 붙여 고정한다. 프로젝트 개요가 이 목록의 첫 건을 "최신 배포" 로 쓰는데, 그 값이 조회마다
+ // 달라질 수 있었다(findFirstByProjectIdOrderByTriggeredAtDescIdDesc 와도 어긋났다).
+ @Query("""
+ select new com.example.dvely.deployment.domain.repository.DeploymentHistoryListView(
+ history.id, history.projectId, history.deployTargetType, history.versionLabel,
+ history.deployedUrl, history.status, history.failureCode, history.errorMessage,
+ history.triggeredAt, history.updatedAt, history.retriedFromHistoryId)
+ from DeploymentHistoryEntity history
+ where history.projectId = :projectId
+ order by history.triggeredAt desc, history.id desc
+ """)
+ List findHistoryListViews(@Param("projectId") Long projectId);
+
+ // U6 6-2 ②: 버전 목록. version_label 이 비지 않은 행만 — 예전에는 전체를 읽어 메모리에서 걸렀다.
+ @Query("""
+ select new com.example.dvely.deployment.domain.repository.DeploymentVersionView(
+ history.id, history.versionLabel, history.commitSha, history.title,
+ history.status, history.deployedUrl, history.triggeredAt, history.mergedAt,
+ history.updatedAt)
+ from DeploymentHistoryEntity history
+ where history.projectId = :projectId
+ and history.versionLabel is not null
+ and trim(history.versionLabel) <> ''
+ order by history.triggeredAt desc, history.id desc
+ """)
+ List findLabeledVersionViews(@Param("projectId") Long projectId);
+
+ // U6 6-2 ③: 배포 후보. ② 에 "LIVE 만" 이 더 붙는다. 두 조건 모두 SQL 에 있다.
+ @Query("""
+ select new com.example.dvely.deployment.domain.repository.DeploymentVersionView(
+ history.id, history.versionLabel, history.commitSha, history.title,
+ history.status, history.deployedUrl, history.triggeredAt, history.mergedAt,
+ history.updatedAt)
+ from DeploymentHistoryEntity history
+ where history.projectId = :projectId
+ and history.status = :liveStatus
+ and history.versionLabel is not null
+ and trim(history.versionLabel) <> ''
+ order by history.triggeredAt desc, history.id desc
+ """)
+ List findLiveLabeledVersionViews(
+ @Param("projectId") Long projectId, @Param("liveStatus") String liveStatus);
+
+ // U6 6-2 ④: DomainBindingCommandService 가 쓰는 "가장 최근 LIVE 1건의 URL". 예전에는 전체 이력을
+ // 엔티티로 읽어 첫 LIVE 하나만 꺼냈다. 값이 비어 있으면 다음 LIVE 로 넘어가지 않는 기존
+ // 동작을 유지하려고 URL 의 공백 판정은 호출부에 남겨둔다 — 여기서 걸러내면 동작이 달라진다.
+ @Query("""
+ select history.deployedUrl
+ from DeploymentHistoryEntity history
+ where history.projectId = :projectId
+ and history.status = :liveStatus
+ order by history.triggeredAt desc, history.id desc
+ """)
+ List findLiveDeployedUrls(
+ @Param("projectId") Long projectId, @Param("liveStatus") String liveStatus, Pageable pageable);
Optional findFirstByProjectIdOrderByTriggeredAtDescIdDesc(Long projectId);
diff --git a/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java b/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java
index 9eb50389..209659e2 100644
--- a/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java
+++ b/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java
@@ -475,11 +475,10 @@ private String resolveDeploymentUrl(Project project) {
if (project.getCurrentUrl() != null && !project.getCurrentUrl().isBlank()) {
return project.getCurrentUrl();
}
- return deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(project.getId()).stream()
- .filter(history -> history.getStatus() == DeployStatus.LIVE)
- .findFirst()
- .map(history -> history.getDeployedUrl())
- .filter(url -> url != null && !url.isBlank())
+ // U6 6-2: 전체 이력을 엔티티로 읽어 첫 LIVE 하나만 꺼내던 자리. 전용 쿼리로 바꿨다.
+ // 공백 판정은 여기 남는다 — 첫 LIVE 의 URL 이 비면 다음 LIVE 로 넘어가지 않는 기존 동작이다.
+ return deploymentHistoryRepository.findLatestLiveDeployedUrl(project.getId())
+ .filter(url -> !url.isBlank())
.orElse(null);
}
diff --git a/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java b/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java
index 207f7943..cab1b84e 100644
--- a/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java
+++ b/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java
@@ -3,6 +3,7 @@
import com.example.dvely.domainbinding.application.command.DomainBindingCommandService;
import com.example.dvely.domainbinding.application.command.dto.BindDomainCommand;
import com.example.dvely.domainbinding.application.query.DomainBindingQueryService;
+import com.example.dvely.common.paging.CursorPage;
import com.example.dvely.domainbinding.application.result.DomainBindingResult;
import com.example.dvely.domainbinding.application.result.DomainSearchResult;
import com.example.dvely.domainbinding.application.result.VerificationGuideResult;
@@ -21,8 +22,11 @@ public DomainSearchResult search(String keyword) {
return queryService.search(keyword);
}
- public List getProjectDomains(Long ownerUserId, Long projectId) {
- return queryService.getProjectDomains(ownerUserId, projectId);
+ public CursorPage getProjectDomains(Long ownerUserId,
+ Long projectId,
+ Integer limit,
+ String after) {
+ return queryService.getProjectDomains(ownerUserId, projectId, limit, after);
}
public DomainBindingResult bindDomain(Long ownerUserId, Long projectId, BindDomainCommand command) {
diff --git a/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java b/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java
index a4d3cc18..6ec63608 100644
--- a/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java
+++ b/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java
@@ -1,6 +1,8 @@
package com.example.dvely.domainbinding.application.query;
import com.example.dvely.common.exception.NotFoundException;
+import com.example.dvely.common.paging.CursorPage;
+import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.domainbinding.application.result.DomainBindingResult;
import com.example.dvely.domainbinding.application.result.DomainSearchCandidateResult;
import com.example.dvely.domainbinding.application.result.DomainSearchResult;
@@ -63,11 +65,29 @@ public DomainSearchResult search(String keyword) {
return new DomainSearchResult(label, results);
}
+ /**
+ * limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 한 프로젝트의 도메인은 사람이 직접
+ * 연결하는 것이라 수십 개면 이미 비정상이고, 200 은 그 훨씬 위다 — 개요의 "현재 도메인" 선택
+ * 로직이 최신순 목록에서 고르므로 상한에 걸려도 고르는 결과는 같다.
+ */
+ private static final int DEFAULT_DOMAIN_LIMIT = 200;
+ private static final int MAX_DOMAIN_LIMIT = 500;
+
public List getProjectDomains(Long ownerUserId, Long projectId) {
+ return getProjectDomains(ownerUserId, projectId, null, null).items();
+ }
+
+ /** U6(#341) 6-4: 상한 + 커서. 최신순이라 {@code after} 는 "그 도메인보다 오래된 것" 을 뜻한다. */
+ public CursorPage getProjectDomains(Long ownerUserId,
+ Long projectId,
+ Integer limit,
+ String after) {
resolveProject(ownerUserId, projectId);
- return domainBindingRepository.findByProjectIdOrderByCreatedAtDesc(projectId).stream()
- .map(this::toResult)
- .toList();
+ int size = CursorPaging.clamp(limit, DEFAULT_DOMAIN_LIMIT, MAX_DOMAIN_LIMIT);
+ List probed = domainBindingRepository.findProjectDomainsPage(
+ projectId, CursorPaging.parseCursor(after), size + 1);
+ return CursorPaging.slice(probed, size, domain -> String.valueOf(domain.getId()))
+ .map(this::toResult);
}
public DomainBindingResult getDomain(Long ownerUserId, Long domainId) {
diff --git a/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java b/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java
index 643c24ae..ce05e853 100644
--- a/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java
+++ b/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java
@@ -13,6 +13,13 @@ public interface DomainBindingRepository {
List findByProjectIdOrderByCreatedAtDesc(Long projectId);
+ /**
+ * U6(#341) 6-4: 사용자에게 내보내는 도메인 목록 한 페이지. 최신순이고 {@code after}(도메인 id,
+ * 배타적)보다 오래된 것만 준다. 위의 무제한 조회는 배포·도메인 로직이 "프로젝트의 전체 도메인"
+ * 으로 쓰므로 그대로 남긴다.
+ */
+ List findProjectDomainsPage(Long projectId, Long after, int limit);
+
/**
* 해당 상태의 도메인을 오래된 순으로 최대 {@code limit} 건 읽는다. 검증 워커가 매 주기마다
* 외부 API(Cloudflare · 호스팅)를 도메인 수만큼 때리므로 한 번에 집는 양을 묶어야 한다.
diff --git a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java
index 9c1b1686..b5d831f7 100644
--- a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java
@@ -41,6 +41,16 @@ public List findByProjectIdOrderByCreatedAtDesc(Long projectId) {
.toList();
}
+ @Override
+ public List findProjectDomainsPage(Long projectId, Long after, int limit) {
+ return springDataRepository
+ .findProjectDomainsPage(projectId, after,
+ org.springframework.data.domain.PageRequest.of(0, limit))
+ .stream()
+ .map(DomainBindingEntity::toDomain)
+ .toList();
+ }
+
@Override
public List findByStatus(DomainStatus status, int limit) {
return springDataRepository
diff --git a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java
index f73a3eeb..4cd44d8c 100644
--- a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java
+++ b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java
@@ -13,6 +13,22 @@ public interface SpringDataDomainBindingRepository extends JpaRepository findByProjectIdOrderByCreatedAtDesc(Long projectId);
+ // U6(#341) 6-4: 목록 응답 전용 페이지. 위 메서드는 배포·도메인 로직 여러 곳이 "프로젝트의 전체
+ // 도메인" 으로 쓰므로 그대로 두고, 사용자에게 내보내는 목록만 상한을 받는다. created_at 이
+ // DATETIME(초) 라 id 를 tiebreaker 로 붙였다 — 커서가 행을 건너뛰거나 두 번 주지 않게.
+ @org.springframework.data.jpa.repository.Query("""
+ select d
+ from DomainBindingEntity d
+ where d.projectId = :projectId
+ and (:after is null or d.id < :after)
+ order by d.createdAt desc, d.id desc
+ """)
+ List findProjectDomainsPage(
+ @org.springframework.data.repository.query.Param("projectId") Long projectId,
+ @org.springframework.data.repository.query.Param("after") Long after,
+ org.springframework.data.domain.Pageable pageable
+ );
+
// status 컬럼은 enum 이 아니라 String 이다(DomainBindingEntity:46).
List findByStatusOrderByCreatedAtAsc(String status, Pageable pageable);
diff --git a/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java b/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java
index 1b4e4508..04066d41 100644
--- a/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java
+++ b/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java
@@ -1,6 +1,7 @@
package com.example.dvely.domainbinding.presentation;
import com.example.dvely.domainbinding.application.command.dto.BindDomainCommand;
+import com.example.dvely.common.paging.CursorResponse;
import com.example.dvely.domainbinding.application.facade.DomainBindingFacade;
import com.example.dvely.domainbinding.application.result.DomainBindingResult;
import com.example.dvely.domainbinding.application.result.DomainSearchResult;
@@ -21,6 +22,7 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -60,13 +62,21 @@ public DomainSearchResponse search(
return toSearchResponse(domainBindingFacade.search(keyword));
}
- @Operation(summary = "프로젝트 도메인 목록 조회", description = "프로젝트에 연결된 도메인 목록을 조회합니다.")
+ @Operation(
+ summary = "프로젝트 도메인 목록 조회",
+ description = "프로젝트에 연결된 도메인 목록을 최신순으로 조회합니다. limit 기본 200, "
+ + "최대 500(초과 시 500으로 보정). 더 오래된 것이 남아 있으면 응답 헤더 "
+ + "X-Qeploy-Next-Cursor 에 다음 커서가 실리며, after 로 넘겨 이어 받습니다."
+ )
@GetMapping("/api/v1/projects/{projectId}/domains")
- public List getProjectDomains(@AuthenticationPrincipal Long ownerUserId,
- @PathVariable Long projectId) {
- return domainBindingFacade.getProjectDomains(ownerUserId, projectId).stream()
- .map(this::toDomainResponse)
- .toList();
+ public ResponseEntity> getProjectDomains(
+ @AuthenticationPrincipal Long ownerUserId,
+ @PathVariable Long projectId,
+ @RequestParam(required = false) Integer limit,
+ @RequestParam(required = false) String after) {
+ return CursorResponse.of(
+ domainBindingFacade.getProjectDomains(ownerUserId, projectId, limit, after)
+ .map(this::toDomainResponse));
}
@Operation(
diff --git a/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java b/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java
index 2c6265a0..2a9d85f3 100644
--- a/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java
+++ b/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java
@@ -1,6 +1,7 @@
package com.example.dvely.environment.application.facade;
import com.example.dvely.environment.application.command.EnvironmentVariableCommandService;
+import com.example.dvely.common.paging.CursorPage;
import com.example.dvely.environment.application.query.EnvironmentVariableQueryService;
import com.example.dvely.environment.application.result.EnvironmentVariableHistoryResult;
import com.example.dvely.environment.application.result.EnvironmentVariableResult;
@@ -15,8 +16,11 @@ public class EnvironmentVariableFacade {
private final EnvironmentVariableQueryService queryService;
private final EnvironmentVariableCommandService commandService;
- public List getVariables(Long userId, Long projectId, String scope) {
- return queryService.getVariables(userId, projectId, scope);
+ public CursorPage getVariables(Long userId,
+ Long projectId,
+ String scope,
+ Integer limit) {
+ return queryService.getVariables(userId, projectId, scope, limit);
}
public List getHistory(Long userId, Long projectId, Integer limit) {
diff --git a/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java b/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java
index b2b8cc64..11379519 100644
--- a/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java
+++ b/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java
@@ -1,16 +1,20 @@
package com.example.dvely.environment.application.query;
import com.example.dvely.common.exception.NotFoundException;
+import com.example.dvely.common.paging.CursorPage;
+import com.example.dvely.common.paging.CursorPaging;
import com.example.dvely.environment.application.result.EnvironmentVariableHistoryResult;
import com.example.dvely.environment.application.result.EnvironmentVariableResult;
import com.example.dvely.environment.domain.model.EnvironmentVariable;
import com.example.dvely.environment.domain.model.EnvironmentVariableHistory;
import com.example.dvely.environment.domain.repository.EnvironmentVariableHistoryRepository;
import com.example.dvely.environment.domain.repository.EnvironmentVariableRepository;
+import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView;
import com.example.dvely.environment.domain.value.EnvironmentScope;
import com.example.dvely.project.domain.exception.ProjectNotFoundException;
import com.example.dvely.project.domain.repository.ProjectRepository;
import java.util.List;
+import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -29,16 +33,41 @@ public class EnvironmentVariableQueryService {
private static final int DEFAULT_HISTORY_LIMIT = 50;
private static final int MAX_HISTORY_LIMIT = 200;
+ /**
+ * 변수 목록의 상한. 환경변수는 사람이 직접 정의하는 값이라 프로젝트당 수십 개가 현실적인 상한이고,
+ * 200 을 넘는 것은 정상 사용이 아니다 — 그래서 커서로 이어 받는 대신 상한만 둔다(정렬 키가
+ * (scope, key) 라 id 커서와 맞지 않는 것도 이유다. 필요해지면 복합 커서로 확장한다).
+ */
+ private static final int DEFAULT_VARIABLE_LIMIT = 200;
+ private static final int MAX_VARIABLE_LIMIT = 500;
+
private final EnvironmentVariableRepository environmentVariableRepository;
private final EnvironmentVariableHistoryRepository environmentVariableHistoryRepository;
private final ProjectRepository projectRepository;
public List getVariables(Long userId, Long projectId, String scopeParam) {
+ return getVariables(userId, projectId, scopeParam, null).items();
+ }
+
+ /**
+ * 변수 목록. U6 6-5 로 값을 두 번에 나눠 읽는다: 메타데이터는 env_value 없이 프로젝션으로,
+ * 평문은 secret 이 아닌 행에 대해서만 따로. 응답은 예전과 같다(secret 이면 value=null) —
+ * 달라진 것은 비밀 평문이 애초에 읽히지 않는다는 점이다.
+ */
+ public CursorPage getVariables(Long userId,
+ Long projectId,
+ String scopeParam,
+ Integer limitParam) {
assertProjectOwner(userId, projectId);
- List variables = scopeParam == null
- ? environmentVariableRepository.findByProjectIdOrderByScopeAscKeyAsc(projectId)
- : environmentVariableRepository.findByProjectIdAndScopeOrderByKeyAsc(projectId, parseScope(scopeParam));
- return variables.stream().map(this::toResult).toList();
+ EnvironmentScope scope = scopeParam == null ? null : parseScope(scopeParam);
+ int size = CursorPaging.clamp(limitParam, DEFAULT_VARIABLE_LIMIT, MAX_VARIABLE_LIMIT);
+ List probed =
+ environmentVariableRepository.findSummaries(projectId, scope, size + 1);
+ CursorPage page = CursorPaging.slice(
+ probed, size, view -> String.valueOf(view.id()));
+ Map plainValues = environmentVariableRepository.findPlainValuesByIds(
+ page.items().stream().map(EnvironmentVariableSummaryView::id).toList());
+ return page.map(view -> toResult(view, plainValues));
}
public List getHistory(Long userId, Long projectId, Integer limitParam) {
@@ -76,6 +105,23 @@ public static EnvironmentScope parseScope(String raw) {
}
}
+ /**
+ * U6 6-5: 목록 경로의 매핑. secret 인 변수는 {@code plainValues} 에 애초에 들어오지 않으므로
+ * value 가 null 이 된다 — 마스킹이 아니라 "읽지 않았다" 다. 결과 JSON 은 design D4 와 같다.
+ */
+ private EnvironmentVariableResult toResult(EnvironmentVariableSummaryView view,
+ Map plainValues) {
+ return new EnvironmentVariableResult(
+ view.id(),
+ view.scope(),
+ view.key(),
+ view.secret() ? null : plainValues.get(view.id()),
+ view.secret(),
+ view.createdAt(),
+ view.updatedAt()
+ );
+ }
+
/** Secret masking happens exactly here: {@code secret ? null : plaintext}, per design D4. */
public EnvironmentVariableResult toResult(EnvironmentVariable variable) {
return new EnvironmentVariableResult(
diff --git a/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java
index 41a20544..132ffc65 100644
--- a/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java
+++ b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java
@@ -2,7 +2,9 @@
import com.example.dvely.environment.domain.model.EnvironmentVariable;
import com.example.dvely.environment.domain.value.EnvironmentScope;
+import java.util.Collection;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
public interface EnvironmentVariableRepository {
@@ -17,5 +19,17 @@ public interface EnvironmentVariableRepository {
List findByProjectIdAndScopeOrderByKeyAsc(Long projectId, EnvironmentScope scope);
+ /**
+ * U6 6-5: 목록 조회 전용 — 값(env_value)을 읽지 않는다. {@code scope} 가 null 이면 전 스코프이고,
+ * 정렬은 (scope asc, key asc) 다. {@link EnvironmentVariableSummaryView} 참고.
+ */
+ List findSummaries(Long projectId, EnvironmentScope scope, int limit);
+
+ /**
+ * U6 6-5: 주어진 id 중 secret 이 아닌 행의 평문 값만. secret 인 행은 조건에서 빠져 AES
+ * 복호화가 돌지 않는다 — 비밀 평문이 메모리에 올라오는 경로 자체를 없애기 위한 분리다.
+ */
+ Map findPlainValuesByIds(Collection ids);
+
void deleteById(Long id);
}
diff --git a/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java
new file mode 100644
index 00000000..2e44b1f0
--- /dev/null
+++ b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java
@@ -0,0 +1,25 @@
+package com.example.dvely.environment.domain.repository;
+
+import java.time.LocalDateTime;
+
+/**
+ * 환경변수 목록 전용 읽기 모델. U6(#341) 6-5.
+ *
+ *
값(env_value)이 없다. 그 컬럼은 MEDIUMTEXT + {@code @Convert(AesEncryptor)} 라 엔티티로
+ * 읽으면 행마다 AES 복호화가 돈다. 그런데 목록 응답은 secret 인 변수의 값을 언제나 null 로 내보낸다
+ * (design D4) — 복호화해서 버리고 있었다.
+ *
+ *
그래서 값은 이 뷰에 담지 않고, secret 이 아닌 행에 대해서만 따로 한 번 더 읽는다
+ * ({@code EnvironmentVariableRepository#findPlainValuesByIds}). 비밀 값은 애초에 메모리에 올라오지
+ * 않으므로, "응답 매핑에서 마스킹한다" 에 의존하던 예전 구조보다 강하다 — 여기에 value 필드를
+ * 추가하지 말 것.
+ */
+public record EnvironmentVariableSummaryView(
+ Long id,
+ String scope,
+ String key,
+ boolean secret,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java
index d0b34325..46329551 100644
--- a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java
+++ b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java
@@ -2,11 +2,16 @@
import com.example.dvely.environment.domain.model.EnvironmentVariable;
import com.example.dvely.environment.domain.repository.EnvironmentVariableRepository;
+import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView;
import com.example.dvely.environment.domain.value.EnvironmentScope;
import com.example.dvely.environment.infrastructure.persistence.entity.EnvironmentVariableEntity;
+import java.util.Collection;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
@Repository
@@ -63,6 +68,27 @@ public List findByProjectIdAndScopeOrderByKeyAsc(Long proje
.toList();
}
+ @Override
+ public List findSummaries(Long projectId, EnvironmentScope scope, int limit) {
+ return springDataRepository.findSummaries(
+ projectId, scope == null ? null : scope.name(), PageRequest.of(0, limit));
+ }
+
+ @Override
+ public Map findPlainValuesByIds(Collection ids) {
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ Map values = new LinkedHashMap<>();
+ for (Object[] row : springDataRepository.findPlainValuesByIds(ids)) {
+ // 값이 NULL 인 행은 스킵한다 — Map#put 은 null 을 담지만 "값이 없다" 와 구분이 안 된다.
+ if (row[1] != null) {
+ values.put((Long) row[0], (String) row[1]);
+ }
+ }
+ return values;
+ }
+
@Override
public void deleteById(Long id) {
springDataRepository.deleteById(id);
diff --git a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java
index 69e2df1a..32f9dc47 100644
--- a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java
+++ b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java
@@ -1,9 +1,14 @@
package com.example.dvely.environment.infrastructure.persistence.repository;
+import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView;
import com.example.dvely.environment.infrastructure.persistence.entity.EnvironmentVariableEntity;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SpringDataEnvironmentVariableRepository extends JpaRepository {
@@ -14,4 +19,26 @@ public interface SpringDataEnvironmentVariableRepository extends JpaRepository findByProjectIdOrderByScopeAscKeyAsc(Long projectId);
List findByProjectIdAndScopeOrderByKeyAsc(Long projectId, String scope);
+
+ // U6 6-5: 목록은 env_value 를 읽지 않는다. scope 가 null 이면 전체 스코프.
+ @Query("""
+ select new com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView(
+ e.id, e.scope, e.key, e.secret, e.createdAt, e.updatedAt)
+ from EnvironmentVariableEntity e
+ where e.projectId = :projectId
+ and (:scope is null or e.scope = :scope)
+ order by e.scope asc, e.key asc
+ """)
+ List findSummaries(
+ @Param("projectId") Long projectId, @Param("scope") String scope, Pageable pageable);
+
+ // U6 6-5: 응답에 실제로 실리는 평문만. secret 인 행은 조건에서 빠지므로 AES 복호화가 아예 돌지
+ // 않는다 — 목록 페이지에 든 id 로만 좁혀, 페이지 밖 행의 값도 읽지 않는다.
+ @Query("""
+ select e.id, e.value
+ from EnvironmentVariableEntity e
+ where e.id in :ids
+ and e.secret = false
+ """)
+ List