diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/controller/SyncAdminController.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/controller/SyncAdminController.java new file mode 100644 index 0000000..410a83d --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/controller/SyncAdminController.java @@ -0,0 +1,129 @@ +package com.opensource.docgrid.domain.sync.controller; + +import java.util.UUID; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.domain.sync.dto.request.IgnoreSyncIssueRequest; +import com.opensource.docgrid.domain.sync.dto.request.RunSyncReconciliationRequest; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminActionResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncEventAdminResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncIssueAdminResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncReconciliationAdminResponse; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencySeverity; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.service.command.SyncAdminCommandService; +import com.opensource.docgrid.domain.sync.service.query.SyncAdminQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.PageResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; + +/** + * ADMIN 전용 Sync 운영 요약·Event·Issue 조회와 감사 가능한 재시도·복구·무시·검사 API를 제공한다. + */ +@Tag(name = "Admin - Sync", description = "관리자 전용 Outbox와 정합성 Reconciliation 운영 API") +@Validated +@RestController +@RequestMapping("/admin/sync") +@RequiredArgsConstructor +public class SyncAdminController { + + private final SyncAdminQueryService syncAdminQueryService; + private final SyncAdminCommandService syncAdminCommandService; + + @Operation(summary = "Sync 운영 요약 조회") + @GetMapping(value = "/summary", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getSummary() { + return ResponseUtils.ok(syncAdminQueryService.getSummary()); + } + + @Operation(summary = "Sync Event 목록 조회") + @GetMapping(value = "/events", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getEvents( + @RequestParam(required = false) SyncEventStatus status, + @RequestParam(required = false) SyncEventType eventType, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ResponseUtils.ok(syncAdminQueryService.getEvents(status, eventType, page, size)); + } + + @Operation(summary = "정합성 Issue 목록 조회") + @GetMapping(value = "/issues", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getIssues( + @RequestParam(required = false) SyncConsistencyIssueStatus status, + @RequestParam(required = false) SyncConsistencyIssueType issueType, + @RequestParam(required = false) SyncConsistencySeverity severity, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ResponseUtils.ok(syncAdminQueryService.getIssues(status, issueType, severity, page, size)); + } + + @Operation(summary = "최종 실패 Sync Event 재시도") + @PostMapping(value = "/events/{eventId}/retry", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> retryEvent( + @PathVariable UUID eventId, + @CurrentUser Long adminUserId + ) { + return ResponseUtils.ok(syncAdminCommandService.retryEvent(eventId, adminUserId)); + } + + @Operation(summary = "정합성 Issue 안전 복구 요청") + @PostMapping(value = "/issues/{issueId}/repair", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> repairIssue( + @PathVariable @Positive Long issueId, + @CurrentUser Long adminUserId + ) { + return ResponseUtils.ok(syncAdminCommandService.repairIssue(issueId, adminUserId)); + } + + @Operation(summary = "정합성 Issue 무시") + @PostMapping(value = "/issues/{issueId}/ignore", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> ignoreIssue( + @PathVariable @Positive Long issueId, + @CurrentUser Long adminUserId, + @RequestBody @Valid IgnoreSyncIssueRequest request + ) { + return ResponseUtils.ok(syncAdminCommandService.ignoreIssue( + issueId, + adminUserId, + request.reason() + )); + } + + @Operation(summary = "Reconciliation Batch 수동 실행") + @PostMapping(value = "/reconcile", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> reconcile( + @CurrentUser Long adminUserId, + @RequestBody @Valid RunSyncReconciliationRequest request + ) { + return ResponseUtils.ok(syncAdminCommandService.reconcile( + request.cursor(), + request.mode(), + adminUserId + )); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/converter/SyncAdminConverter.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/converter/SyncAdminConverter.java new file mode 100644 index 0000000..876b94e --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/converter/SyncAdminConverter.java @@ -0,0 +1,70 @@ +package com.opensource.docgrid.domain.sync.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminActionResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncEventAdminResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncIssueAdminResponse; +import com.opensource.docgrid.domain.sync.entity.SyncAdminAction; +import com.opensource.docgrid.domain.sync.entity.SyncConsistencyIssue; +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; + +/** + * Sync 운영 Entity를 내부 소유권·Payload·오류 메시지가 제거된 관리자 DTO로 변환한다. + */ +@Component +public class SyncAdminConverter { + + public SyncEventAdminResponse toEventResponse(SyncOutboxEvent event) { + return new SyncEventAdminResponse( + event.getEventId(), + event.getIdempotencyKey(), + event.getAggregateType(), + event.getAggregateId(), + event.getAggregateVersion(), + event.getEventType(), + event.getStatus(), + event.getOccurredAt(), + event.getAvailableAt(), + event.getProcessedAt(), + event.getRetryCount(), + event.getMaxRetryCount(), + event.getLockedBy(), + event.getLockExpiresAt(), + event.getLastErrorCode() + ); + } + + public SyncIssueAdminResponse toIssueResponse(SyncConsistencyIssue issue) { + return new SyncIssueAdminResponse( + issue.getId(), + issue.getIssueKey(), + issue.getIssueType(), + issue.getSeverity(), + issue.getStatus(), + issue.getDocument() == null ? null : issue.getDocument().getId(), + issue.getDocumentVersion() == null ? null : issue.getDocumentVersion().getId(), + issue.getEmbeddingModel() == null ? null : issue.getEmbeddingModel().getId(), + issue.getExpectedJson(), + issue.getActualJson(), + issue.isRepairable(), + issue.getDetectedAt(), + issue.getLastDetectedAt(), + issue.getRepairEventId(), + issue.getRepairAttemptCount(), + issue.getResolvedAt(), + issue.getResolutionMessage() + ); + } + + public SyncAdminActionResponse toActionResponse(SyncAdminAction action) { + return new SyncAdminActionResponse( + action.getActionId(), + action.getActionType(), + action.getTargetType(), + action.getTargetId(), + action.getAdminUser().getId(), + action.getOccurredAt() + ); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/request/IgnoreSyncIssueRequest.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/request/IgnoreSyncIssueRequest.java new file mode 100644 index 0000000..5683648 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/request/IgnoreSyncIssueRequest.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.sync.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 관리자가 자동 조치하지 않을 정합성 Issue에 남기는 감사 사유다. + */ +public record IgnoreSyncIssueRequest( + @NotBlank + @Size(max = 1000) + String reason +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/request/RunSyncReconciliationRequest.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/request/RunSyncReconciliationRequest.java new file mode 100644 index 0000000..41c078d --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/request/RunSyncReconciliationRequest.java @@ -0,0 +1,18 @@ +package com.opensource.docgrid.domain.sync.dto.request; + +import com.opensource.docgrid.domain.sync.enums.SyncReconciliationMode; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +/** + * 관리자가 실행할 Reconciliation 모드와 시작 ID Cursor를 지정한다. + */ +public record RunSyncReconciliationRequest( + @NotNull + SyncReconciliationMode mode, + + @PositiveOrZero + long cursor +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncAdminActionResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncAdminActionResponse.java new file mode 100644 index 0000000..1de426e --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncAdminActionResponse.java @@ -0,0 +1,20 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.opensource.docgrid.domain.sync.enums.SyncAdminActionType; +import com.opensource.docgrid.domain.sync.enums.SyncAdminTargetType; + +/** + * 상태 변경 요청이 저장된 감사 Action 식별자와 실행자를 반환한다. + */ +public record SyncAdminActionResponse( + UUID actionId, + SyncAdminActionType actionType, + SyncAdminTargetType targetType, + String targetId, + Long adminUserId, + LocalDateTime occurredAt +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncAdminSummaryResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncAdminSummaryResponse.java new file mode 100644 index 0000000..5acbf72 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncAdminSummaryResponse.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.time.LocalDateTime; + +/** + * 관리자 Dashboard가 한 번에 조회하는 Outbox·Issue·Reconciliation 운영 Snapshot이다. + */ +public record SyncAdminSummaryResponse( + LocalDateTime capturedAt, + SyncEventSummaryResponse events, + SyncIssueSummaryResponse issues, + SyncReconciliationSummaryResponse reconciliation +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncEventAdminResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncEventAdminResponse.java new file mode 100644 index 0000000..7097e45 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncEventAdminResponse.java @@ -0,0 +1,30 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.opensource.docgrid.domain.sync.enums.SyncAggregateType; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; + +/** + * 관리자에게 Payload와 오류 메시지를 제외하고 공개하는 Sync Event 운영 Snapshot이다. + */ +public record SyncEventAdminResponse( + UUID eventId, + String idempotencyKey, + SyncAggregateType aggregateType, + Long aggregateId, + Long aggregateVersion, + SyncEventType eventType, + SyncEventStatus status, + LocalDateTime occurredAt, + LocalDateTime availableAt, + LocalDateTime processedAt, + int retryCount, + int maxRetryCount, + String lockedBy, + LocalDateTime lockExpiresAt, + String lastErrorCode +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncEventSummaryResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncEventSummaryResponse.java new file mode 100644 index 0000000..dbc793a --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncEventSummaryResponse.java @@ -0,0 +1,21 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Outbox Queue의 현재 적체와 최근 24시간 처리 품질 Snapshot이다. + */ +public record SyncEventSummaryResponse( + long pendingCount, + long processingCount, + long failedCount, + Long oldestPendingAgeSeconds, + long processedLast24hCount, + long failedLast24hCount, + long retriedLast24hCount, + double successRateLast24h, + UUID lastProcessedEventId, + LocalDateTime lastProcessedAt +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncIssueAdminResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncIssueAdminResponse.java new file mode 100644 index 0000000..6b143cf --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncIssueAdminResponse.java @@ -0,0 +1,32 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencySeverity; + +/** + * 관리자가 불일치 근거와 복구 생명주기를 판단하는 정합성 Issue 응답이다. + */ +public record SyncIssueAdminResponse( + Long issueId, + String issueKey, + SyncConsistencyIssueType issueType, + SyncConsistencySeverity severity, + SyncConsistencyIssueStatus status, + Long documentId, + Long documentVersionId, + Long embeddingModelId, + String expectedJson, + String actualJson, + boolean repairable, + LocalDateTime detectedAt, + LocalDateTime lastDetectedAt, + UUID repairEventId, + int repairAttemptCount, + LocalDateTime resolvedAt, + String resolutionMessage +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncIssueSummaryResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncIssueSummaryResponse.java new file mode 100644 index 0000000..fe25ec2 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncIssueSummaryResponse.java @@ -0,0 +1,12 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +/** + * 정합성 Issue의 활성 상태와 최근 자동 복구 결과 Snapshot이다. + */ +public record SyncIssueSummaryResponse( + long openCount, + long repairingCount, + long autoResolvedLast24hCount, + long failedRepairCount +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncReconciliationAdminResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncReconciliationAdminResponse.java new file mode 100644 index 0000000..315a97e --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncReconciliationAdminResponse.java @@ -0,0 +1,18 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.util.UUID; + +/** + * 수동 Reconciliation Batch 결과와 이를 추적할 감사 Action을 함께 반환한다. + */ +public record SyncReconciliationAdminResponse( + UUID runId, + long startCursor, + long endCursor, + int scannedCount, + int detectedCount, + int repairRequestedCount, + boolean hasMore, + SyncAdminActionResponse action +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncReconciliationSummaryResponse.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncReconciliationSummaryResponse.java new file mode 100644 index 0000000..2e01887 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/dto/response/SyncReconciliationSummaryResponse.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.sync.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.opensource.docgrid.domain.sync.enums.SyncReconciliationMode; +import com.opensource.docgrid.domain.sync.enums.SyncReconciliationStatus; + +/** + * 가장 최근 Reconciliation Batch의 범위와 탐지·복구 결과다. + */ +public record SyncReconciliationSummaryResponse( + UUID runId, + SyncReconciliationMode mode, + SyncReconciliationStatus status, + long startCursor, + long endCursor, + int scannedCount, + int detectedCount, + int repairRequestedCount, + LocalDateTime startedAt, + LocalDateTime completedAt, + String errorCode +) { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncAdminAction.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncAdminAction.java new file mode 100644 index 0000000..f70245b --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncAdminAction.java @@ -0,0 +1,99 @@ +package com.opensource.docgrid.domain.sync.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.opensource.docgrid.domain.sync.enums.SyncAdminActionType; +import com.opensource.docgrid.domain.sync.enums.SyncAdminTargetType; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.global.common.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Event 재시도·Issue 복구/무시·수동 Reconciliation을 실행한 관리자와 이유를 append-only로 보존한다. + * + *

대상 도메인 상태는 각 Entity가 담당하고 이 Entity는 누가 언제 어떤 명령을 실행했는지만 감사한다. + */ +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "sync_admin_actions", + uniqueConstraints = @UniqueConstraint(name = "uk_sync_admin_actions_action_id", columnNames = "action_id"), + indexes = { + @Index(name = "idx_sync_admin_actions_admin_occurred", columnList = "admin_user_id, occurred_at, id"), + @Index(name = "idx_sync_admin_actions_type_occurred", columnList = "action_type, occurred_at, id"), + @Index(name = "idx_sync_admin_actions_target", columnList = "target_type, target_id, occurred_at") + } +) +public class SyncAdminAction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "action_id", nullable = false, updatable = false) + private UUID actionId; + + @Enumerated(EnumType.STRING) + @Column(name = "action_type", nullable = false, updatable = false, length = 50) + private SyncAdminActionType actionType; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, updatable = false, length = 30) + private SyncAdminTargetType targetType; + + @Column(name = "target_id", nullable = false, updatable = false, length = 100) + private String targetId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_user_id", nullable = false, updatable = false) + private User adminUser; + + @Column(columnDefinition = "TEXT", updatable = false) + private String reason; + + @Column(name = "metadata_json", columnDefinition = "TEXT", updatable = false) + private String metadataJson; + + @Column(name = "occurred_at", nullable = false, updatable = false) + private LocalDateTime occurredAt; + + @Builder + public SyncAdminAction( + UUID actionId, + SyncAdminActionType actionType, + SyncAdminTargetType targetType, + String targetId, + User adminUser, + String reason, + String metadataJson, + LocalDateTime occurredAt + ) { + this.actionId = actionId; + this.actionType = actionType; + this.targetType = targetType; + this.targetId = targetId; + this.adminUser = adminUser; + this.reason = reason; + this.metadataJson = metadataJson; + this.occurredAt = occurredAt; + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncConsistencyIssue.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncConsistencyIssue.java index fbd2272..bd4d1bd 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncConsistencyIssue.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/entity/SyncConsistencyIssue.java @@ -90,6 +90,9 @@ public class SyncConsistencyIssue extends BaseEntity { @Column(name = "actual_json", columnDefinition = "TEXT") private String actualJson; + @Column(nullable = false) + private boolean repairable; + @Column(name = "detected_at", nullable = false, updatable = false) private LocalDateTime detectedAt; @@ -118,6 +121,7 @@ public SyncConsistencyIssue( EmbeddingModel embeddingModel, String expectedJson, String actualJson, + boolean repairable, LocalDateTime detectedAt ) { this.issueKey = issueKey; @@ -129,6 +133,7 @@ public SyncConsistencyIssue( this.embeddingModel = embeddingModel; this.expectedJson = expectedJson; this.actualJson = actualJson; + this.repairable = repairable; this.detectedAt = detectedAt; this.lastDetectedAt = detectedAt; } @@ -137,11 +142,13 @@ public void detectAgain( SyncConsistencySeverity newSeverity, String newExpectedJson, String newActualJson, + boolean newRepairable, LocalDateTime detectedAgainAt ) { severity = newSeverity; expectedJson = newExpectedJson; actualJson = newActualJson; + repairable = newRepairable; lastDetectedAt = detectedAgainAt; if (status == SyncConsistencyIssueStatus.RESOLVED) { status = SyncConsistencyIssueStatus.OPEN; diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/enums/SyncAdminActionType.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/enums/SyncAdminActionType.java new file mode 100644 index 0000000..862cffc --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/enums/SyncAdminActionType.java @@ -0,0 +1,11 @@ +package com.opensource.docgrid.domain.sync.enums; + +/** + * 관리자가 Sync 운영 상태에 수행한 감사 대상 명령 유형이다. + */ +public enum SyncAdminActionType { + EVENT_RETRIED, + ISSUE_REPAIR_REQUESTED, + ISSUE_IGNORED, + RECONCILIATION_REQUESTED +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/enums/SyncAdminTargetType.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/enums/SyncAdminTargetType.java new file mode 100644 index 0000000..52cf9e0 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/enums/SyncAdminTargetType.java @@ -0,0 +1,10 @@ +package com.opensource.docgrid.domain.sync.enums; + +/** + * Sync 관리자 감사 명령이 가리키는 대상 종류다. + */ +public enum SyncAdminTargetType { + SYNC_EVENT, + CONSISTENCY_ISSUE, + RECONCILIATION +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncAdminActionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncAdminActionRepository.java new file mode 100644 index 0000000..83b6938 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncAdminActionRepository.java @@ -0,0 +1,16 @@ +package com.opensource.docgrid.domain.sync.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.sync.entity.SyncAdminAction; + +/** + * Sync 관리자 명령 감사 이력을 append-only로 저장한다. + */ +public interface SyncAdminActionRepository extends JpaRepository { + + Optional findByActionId(UUID actionId); +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncConsistencyIssueRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncConsistencyIssueRepository.java index 32181dd..b137520 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncConsistencyIssueRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncConsistencyIssueRepository.java @@ -1,18 +1,35 @@ package com.opensource.docgrid.domain.sync.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import jakarta.persistence.LockModeType; + +import org.springframework.data.domain.Page; +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; +import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.sync.entity.SyncConsistencyIssue; import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencySeverity; /** * 일관성 Issue의 멱등 Key 조회와 관리자 목록 기반을 제공한다. */ public interface SyncConsistencyIssueRepository extends JpaRepository { + long countByStatus(SyncConsistencyIssueStatus status); + + long countByStatusAndRepairEventIdIsNotNullAndResolvedAtGreaterThanEqual( + SyncConsistencyIssueStatus status, + LocalDateTime since + ); + Optional findByIssueKey(String issueKey); List findAllByDocumentVersionIdAndStatusIn( @@ -23,4 +40,43 @@ List findAllByDocumentVersionIdAndStatusIn( List findAllByDocumentVersionIsNullAndStatusIn( List statuses ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT issue FROM SyncConsistencyIssue issue WHERE issue.id = :issueId") + Optional findByIdForUpdate(@Param("issueId") Long issueId); + + @Query( + value = """ + SELECT issue + FROM SyncConsistencyIssue issue + LEFT JOIN FETCH issue.document + LEFT JOIN FETCH issue.documentVersion + LEFT JOIN FETCH issue.embeddingModel + WHERE (:status IS NULL OR issue.status = :status) + AND (:issueType IS NULL OR issue.issueType = :issueType) + AND (:severity IS NULL OR issue.severity = :severity) + """, + countQuery = """ + SELECT COUNT(issue) + FROM SyncConsistencyIssue issue + WHERE (:status IS NULL OR issue.status = :status) + AND (:issueType IS NULL OR issue.issueType = :issueType) + AND (:severity IS NULL OR issue.severity = :severity) + """ + ) + Page findAdminIssues( + @Param("status") SyncConsistencyIssueStatus status, + @Param("issueType") SyncConsistencyIssueType issueType, + @Param("severity") SyncConsistencySeverity severity, + Pageable pageable + ); + + @Query(value = """ + SELECT COUNT(*) + FROM sync_consistency_issues issue + JOIN sync_outbox_events event ON event.event_id = issue.repair_event_id + WHERE issue.status = 'REPAIRING' + AND event.status = 'FAILED' + """, nativeQuery = true) + long countFailedRepairIssues(); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncOutboxEventRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncOutboxEventRepository.java index fca3f12..22fc269 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncOutboxEventRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncOutboxEventRepository.java @@ -1,18 +1,22 @@ package com.opensource.docgrid.domain.sync.repository; -import java.util.Optional; -import java.util.List; import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import java.util.UUID; import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +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; import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; /** * 동기화 Outbox Event의 영속성과 멱등 식별자 조회를 담당한다. @@ -22,6 +26,31 @@ */ public interface SyncOutboxEventRepository extends JpaRepository { + long countByStatus(SyncEventStatus status); + + long countByStatusAndProcessedAtGreaterThanEqual(SyncEventStatus status, LocalDateTime since); + + long countByStatusAndUpdatedAtGreaterThanEqual(SyncEventStatus status, LocalDateTime since); + + long countByUpdatedAtGreaterThanEqualAndRetryCountGreaterThan(LocalDateTime since, int retryCount); + + @Query("SELECT MIN(event.occurredAt) FROM SyncOutboxEvent event WHERE event.status = :status") + Optional findOldestOccurredAtByStatus(@Param("status") SyncEventStatus status); + + Optional findTopByStatusOrderByProcessedAtDescIdDesc(SyncEventStatus status); + + @Query(""" + SELECT event + FROM SyncOutboxEvent event + WHERE (:status IS NULL OR event.status = :status) + AND (:eventType IS NULL OR event.eventType = :eventType) + """) + Page findAdminEvents( + @Param("status") SyncEventStatus status, + @Param("eventType") SyncEventType eventType, + Pageable pageable + ); + Optional findByEventId(UUID eventId); Optional findByIdempotencyKey(String idempotencyKey); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncReconciliationRunRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncReconciliationRunRepository.java index 2d48690..b299e5a 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncReconciliationRunRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/repository/SyncReconciliationRunRepository.java @@ -13,4 +13,6 @@ public interface SyncReconciliationRunRepository extends JpaRepository { Optional findByRunId(UUID runId); + + Optional findTopByOrderByStartedAtDescIdDesc(); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminActionWriter.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminActionWriter.java new file mode 100644 index 0000000..a6e7b7d --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminActionWriter.java @@ -0,0 +1,51 @@ +package com.opensource.docgrid.domain.sync.service.command; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.sync.entity.SyncAdminAction; +import com.opensource.docgrid.domain.sync.enums.SyncAdminActionType; +import com.opensource.docgrid.domain.sync.enums.SyncAdminTargetType; +import com.opensource.docgrid.domain.sync.repository.SyncAdminActionRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 관리자 Sync 명령과 같은 Transaction에서 append-only 감사 Action을 기록한다. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class SyncAdminActionWriter { + + private final SyncAdminActionRepository syncAdminActionRepository; + private final UserRepository userRepository; + private final Clock clock; + + public SyncAdminAction record( + Long adminUserId, + SyncAdminActionType actionType, + SyncAdminTargetType targetType, + String targetId, + String reason, + String metadataJson + ) { + return syncAdminActionRepository.save( + SyncAdminAction.builder() + .actionId(UUID.randomUUID()) + .actionType(actionType) + .targetType(targetType) + .targetId(targetId) + .adminUser(userRepository.getReferenceById(adminUserId)) + .reason(reason) + .metadataJson(metadataJson) + .occurredAt(LocalDateTime.now(clock)) + .build() + ); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminCommandService.java new file mode 100644 index 0000000..9d7ab88 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminCommandService.java @@ -0,0 +1,154 @@ +package com.opensource.docgrid.domain.sync.service.command; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.sync.converter.SyncAdminConverter; +import com.opensource.docgrid.domain.sync.dto.SyncReconciliationBatchResult; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminActionResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncReconciliationAdminResponse; +import com.opensource.docgrid.domain.sync.entity.SyncAdminAction; +import com.opensource.docgrid.domain.sync.entity.SyncConsistencyIssue; +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.enums.SyncAdminActionType; +import com.opensource.docgrid.domain.sync.enums.SyncAdminTargetType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncReconciliationMode; +import com.opensource.docgrid.domain.sync.repository.SyncConsistencyIssueRepository; +import com.opensource.docgrid.domain.sync.service.SyncReconciliationOrchestrator; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +/** + * 관리자 Event 재시도·Issue 복구/무시·수동 Reconciliation을 감사 Action과 함께 조율한다. + * + *

Event와 Issue 변경은 감사 행과 같은 Transaction이며, 별도 실행 이력을 가진 Reconciliation은 성공 + * 결과를 받은 뒤 독립 감사 Transaction을 기록한다. + */ +@Service +@RequiredArgsConstructor +public class SyncAdminCommandService { + + private final SyncEventManualRetryService syncEventManualRetryService; + private final SyncConsistencyIssueRepository syncConsistencyIssueRepository; + private final SyncEventWriter syncEventWriter; + private final SyncReconciliationOrchestrator syncReconciliationOrchestrator; + private final SyncAdminActionWriter syncAdminActionWriter; + private final SyncAdminConverter syncAdminConverter; + private final Clock clock; + + @Transactional + public SyncAdminActionResponse retryEvent(UUID eventId, Long adminUserId) { + // 1. FAILED Event를 즉시 Claim 가능한 Queue 상태로 되돌린다. + syncEventManualRetryService.retry(eventId); + // 2. 상태 변경과 같은 Transaction에 실행 관리자를 감사한다. + return actionResponse(syncAdminActionWriter.record( + adminUserId, + SyncAdminActionType.EVENT_RETRIED, + SyncAdminTargetType.SYNC_EVENT, + eventId.toString(), + null, + null + )); + } + + @Transactional + public SyncAdminActionResponse repairIssue(Long issueId, Long adminUserId) { + // 1. 같은 Issue의 동시 복구 요청을 행 잠금으로 직렬화한다. + SyncConsistencyIssue issue = findLockedIssue(issueId); + validateRepairable(issue); + LocalDateTime requestedAt = LocalDateTime.now(clock); + + // 2. 직접 Job을 조작하지 않고 기존 Dispatcher가 처리할 멱등 Outbox Event를 만든다. + SyncOutboxEvent event = syncEventWriter.recordDocumentReindexRequested( + issue.getDocumentVersion(), + issue.getEmbeddingModel(), + "admin:%d:issue:%d:attempt:%d".formatted( + adminUserId, + issue.getId(), + issue.getRepairAttemptCount() + 1 + ) + ); + issue.markRepairing(event.getEventId(), requestedAt); + + // 3. Repair Event 식별자를 감사 Metadata로 남겨 처리 결과까지 추적할 수 있게 한다. + return actionResponse(syncAdminActionWriter.record( + adminUserId, + SyncAdminActionType.ISSUE_REPAIR_REQUESTED, + SyncAdminTargetType.CONSISTENCY_ISSUE, + issueId.toString(), + null, + "{\"repairEventId\":\"%s\"}".formatted(event.getEventId()) + )); + } + + @Transactional + public SyncAdminActionResponse ignoreIssue(Long issueId, Long adminUserId, String reason) { + SyncConsistencyIssue issue = findLockedIssue(issueId); + if (issue.getStatus() != SyncConsistencyIssueStatus.OPEN) { + throw new DocGridException(ErrorCode.SYNC_ISSUE_IGNORE_NOT_ALLOWED); + } + String normalizedReason = reason.trim(); + issue.ignore(LocalDateTime.now(clock), normalizedReason); + return actionResponse(syncAdminActionWriter.record( + adminUserId, + SyncAdminActionType.ISSUE_IGNORED, + SyncAdminTargetType.CONSISTENCY_ISSUE, + issueId.toString(), + normalizedReason, + null + )); + } + + public SyncReconciliationAdminResponse reconcile( + long cursor, + SyncReconciliationMode mode, + Long adminUserId + ) { + // Reconciler가 실행/실패 이력을 자체 Transaction으로 확정한 뒤 성공 실행만 관리자 감사에 연결한다. + SyncReconciliationBatchResult result = syncReconciliationOrchestrator.reconcileBatch(cursor, mode); + SyncAdminAction action = syncAdminActionWriter.record( + adminUserId, + SyncAdminActionType.RECONCILIATION_REQUESTED, + SyncAdminTargetType.RECONCILIATION, + result.runId().toString(), + null, + "{\"mode\":\"%s\",\"startCursor\":%d,\"endCursor\":%d}" + .formatted(mode, result.startCursor(), result.endCursor()) + ); + return new SyncReconciliationAdminResponse( + result.runId(), + result.startCursor(), + result.endCursor(), + result.scannedCount(), + result.detectedCount(), + result.repairRequestedCount(), + result.hasMore(), + actionResponse(action) + ); + } + + private SyncConsistencyIssue findLockedIssue(Long issueId) { + return syncConsistencyIssueRepository.findByIdForUpdate(issueId) + .orElseThrow(() -> new DocGridException(ErrorCode.SYNC_ISSUE_NOT_FOUND)); + } + + private void validateRepairable(SyncConsistencyIssue issue) { + if (issue.getStatus() != SyncConsistencyIssueStatus.OPEN + || !issue.isRepairable() + || issue.getDocumentVersion() == null + || issue.getEmbeddingModel() == null) { + throw new DocGridException(ErrorCode.SYNC_ISSUE_REPAIR_NOT_ALLOWED); + } + } + + private SyncAdminActionResponse actionResponse(SyncAdminAction action) { + return syncAdminConverter.toActionResponse(action); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncConsistencyIssueService.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncConsistencyIssueService.java index 1988696..87923f3 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncConsistencyIssueService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncConsistencyIssueService.java @@ -47,6 +47,7 @@ public SyncConsistencyIssue detect( .embeddingModel(observation.embeddingModel()) .expectedJson(observation.expectedJson()) .actualJson(observation.actualJson()) + .repairable(observation.repairable()) .detectedAt(detectedAt) .build() )); @@ -84,6 +85,7 @@ private SyncConsistencyIssue detectAgain( observation.severity(), observation.expectedJson(), observation.actualJson(), + observation.repairable(), detectedAt ); return issue; diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/query/SyncAdminQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/query/SyncAdminQueryService.java new file mode 100644 index 0000000..ec67e2f --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/query/SyncAdminQueryService.java @@ -0,0 +1,180 @@ +package com.opensource.docgrid.domain.sync.service.query; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.sync.converter.SyncAdminConverter; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncEventAdminResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncEventSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncIssueAdminResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncIssueSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncReconciliationSummaryResponse; +import com.opensource.docgrid.domain.sync.entity.SyncConsistencyIssue; +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.entity.SyncReconciliationRun; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencySeverity; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.repository.SyncConsistencyIssueRepository; +import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; +import com.opensource.docgrid.domain.sync.repository.SyncReconciliationRunRepository; +import com.opensource.docgrid.global.common.response.PageResponse; + +import lombok.RequiredArgsConstructor; + +/** + * 관리자 Dashboard용 Outbox 적체·처리 품질·정합성 Issue와 실행 이력을 읽기 전용으로 집계한다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SyncAdminQueryService { + + private static final Sort EVENT_SORT = Sort.by( + Sort.Order.desc("occurredAt"), + Sort.Order.desc("id") + ); + private static final Sort ISSUE_SORT = Sort.by( + Sort.Order.desc("lastDetectedAt"), + Sort.Order.desc("id") + ); + + private final SyncOutboxEventRepository syncOutboxEventRepository; + private final SyncConsistencyIssueRepository syncConsistencyIssueRepository; + private final SyncReconciliationRunRepository syncReconciliationRunRepository; + private final SyncAdminConverter syncAdminConverter; + private final Clock clock; + + public SyncAdminSummaryResponse getSummary() { + LocalDateTime capturedAt = LocalDateTime.now(clock); + LocalDateTime since = capturedAt.minusHours(24); + return new SyncAdminSummaryResponse( + capturedAt, + eventSummary(capturedAt, since), + issueSummary(since), + reconciliationSummary() + ); + } + + public PageResponse getEvents( + SyncEventStatus status, + SyncEventType eventType, + int page, + int size + ) { + Page events = syncOutboxEventRepository.findAdminEvents( + status, + eventType, + PageRequest.of(page, size, EVENT_SORT) + ); + List content = events.getContent().stream() + .map(syncAdminConverter::toEventResponse) + .toList(); + return PageResponse.from(events, content); + } + + public PageResponse getIssues( + SyncConsistencyIssueStatus status, + SyncConsistencyIssueType issueType, + SyncConsistencySeverity severity, + int page, + int size + ) { + Page issues = syncConsistencyIssueRepository.findAdminIssues( + status, + issueType, + severity, + PageRequest.of(page, size, ISSUE_SORT) + ); + List content = issues.getContent().stream() + .map(syncAdminConverter::toIssueResponse) + .toList(); + return PageResponse.from(issues, content); + } + + private SyncEventSummaryResponse eventSummary(LocalDateTime capturedAt, LocalDateTime since) { + long processedCount = syncOutboxEventRepository.countByStatusAndProcessedAtGreaterThanEqual( + SyncEventStatus.PROCESSED, + since + ); + long failedCount = syncOutboxEventRepository.countByStatusAndUpdatedAtGreaterThanEqual( + SyncEventStatus.FAILED, + since + ); + Optional lastProcessed = syncOutboxEventRepository + .findTopByStatusOrderByProcessedAtDescIdDesc(SyncEventStatus.PROCESSED); + return new SyncEventSummaryResponse( + syncOutboxEventRepository.countByStatus(SyncEventStatus.PENDING), + syncOutboxEventRepository.countByStatus(SyncEventStatus.PROCESSING), + syncOutboxEventRepository.countByStatus(SyncEventStatus.FAILED), + oldestPendingAgeSeconds(capturedAt), + processedCount, + failedCount, + syncOutboxEventRepository.countByUpdatedAtGreaterThanEqualAndRetryCountGreaterThan(since, 0), + successRate(processedCount, failedCount), + lastProcessed.map(SyncOutboxEvent::getEventId).orElse(null), + lastProcessed.map(SyncOutboxEvent::getProcessedAt).orElse(null) + ); + } + + private SyncIssueSummaryResponse issueSummary(LocalDateTime since) { + return new SyncIssueSummaryResponse( + syncConsistencyIssueRepository.countByStatus(SyncConsistencyIssueStatus.OPEN), + syncConsistencyIssueRepository.countByStatus(SyncConsistencyIssueStatus.REPAIRING), + syncConsistencyIssueRepository + .countByStatusAndRepairEventIdIsNotNullAndResolvedAtGreaterThanEqual( + SyncConsistencyIssueStatus.RESOLVED, + since + ), + syncConsistencyIssueRepository.countFailedRepairIssues() + ); + } + + private SyncReconciliationSummaryResponse reconciliationSummary() { + return syncReconciliationRunRepository.findTopByOrderByStartedAtDescIdDesc() + .map(this::toReconciliationSummary) + .orElse(null); + } + + private SyncReconciliationSummaryResponse toReconciliationSummary(SyncReconciliationRun run) { + return new SyncReconciliationSummaryResponse( + run.getRunId(), + run.getMode(), + run.getStatus(), + run.getStartCursor(), + run.getEndCursor(), + run.getScannedCount(), + run.getDetectedCount(), + run.getRepairRequestedCount(), + run.getStartedAt(), + run.getCompletedAt(), + run.getErrorCode() + ); + } + + private Long oldestPendingAgeSeconds(LocalDateTime capturedAt) { + return syncOutboxEventRepository.findOldestOccurredAtByStatus(SyncEventStatus.PENDING) + .map(occurredAt -> Math.max(0L, Duration.between(occurredAt, capturedAt).toSeconds())) + .orElse(null); + } + + private double successRate(long processedCount, long failedCount) { + long completedCount = processedCount + failedCount; + if (completedCount == 0) { + return 100.0; + } + return Math.round(processedCount * 1000.0 / completedCount) / 10.0; + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 499166e..b57bd63 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -261,6 +261,21 @@ public enum ErrorCode { "SYNC-004", "최종 실패한 동기화 Event만 재처리할 수 있습니다." ), + SYNC_ISSUE_NOT_FOUND( + HttpStatus.NOT_FOUND, + "SYNC-005", + "동기화 정합성 Issue를 찾을 수 없습니다." + ), + SYNC_ISSUE_REPAIR_NOT_ALLOWED( + HttpStatus.CONFLICT, + "SYNC-006", + "현재 Issue는 안전한 자동 복구를 요청할 수 없습니다." + ), + SYNC_ISSUE_IGNORE_NOT_ALLOWED( + HttpStatus.CONFLICT, + "SYNC-007", + "OPEN 상태의 Issue만 무시할 수 있습니다." + ), // SEARCH EMBEDDING_SERVER_UNAVAILABLE( diff --git a/backend/src/main/resources/db/migration/V38__create_sync_admin_actions.sql b/backend/src/main/resources/db/migration/V38__create_sync_admin_actions.sql new file mode 100644 index 0000000..937f1fc --- /dev/null +++ b/backend/src/main/resources/db/migration/V38__create_sync_admin_actions.sql @@ -0,0 +1,27 @@ +-- Reconciler가 판정한 자동 복구 가능 여부를 관리자 제어 경계에서도 재사용한다. +ALTER TABLE sync_consistency_issues + ADD COLUMN repairable BOOLEAN NOT NULL DEFAULT FALSE; + +-- sync_admin_actions: 운영자가 실행한 Sync 상태 변경을 삭제 불가능한 감사 이력으로 보존한다. +CREATE TABLE sync_admin_actions ( + id BIGSERIAL PRIMARY KEY, + action_id UUID NOT NULL, + action_type VARCHAR(50) NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id VARCHAR(100) NOT NULL, + admin_user_id BIGINT NOT NULL REFERENCES users (id), + reason TEXT, + metadata_json TEXT, + occurred_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uk_sync_admin_actions_action_id UNIQUE (action_id) +); + +CREATE INDEX idx_sync_admin_actions_admin_occurred + ON sync_admin_actions (admin_user_id, occurred_at DESC, id DESC); +CREATE INDEX idx_sync_admin_actions_type_occurred + ON sync_admin_actions (action_type, occurred_at DESC, id DESC); +CREATE INDEX idx_sync_admin_actions_target + ON sync_admin_actions (target_type, target_id, occurred_at DESC); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java new file mode 100644 index 0000000..f8f2e5f --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java @@ -0,0 +1,121 @@ +package com.opensource.docgrid.domain.sync.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncEventSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncIssueSummaryResponse; +import com.opensource.docgrid.domain.sync.dto.response.SyncReconciliationAdminResponse; +import com.opensource.docgrid.domain.sync.enums.SyncReconciliationMode; +import com.opensource.docgrid.domain.sync.service.command.SyncAdminCommandService; +import com.opensource.docgrid.domain.sync.service.query.SyncAdminQueryService; +import com.opensource.docgrid.global.config.SecurityConfig; + +/** + * Sync 관리자 조회·수동 Reconciliation API의 응답, Validation과 ADMIN 권한 경계를 검증한다. + */ +@WebMvcTest(SyncAdminController.class) +@Import(SecurityConfig.class) +@DisplayName("SyncAdminController 테스트") +class SyncAdminControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private SyncAdminQueryService syncAdminQueryService; + @MockitoBean private SyncAdminCommandService syncAdminCommandService; + @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; + @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; + @MockitoBean private CorsConfigurationSource corsConfigurationSource; + + @Test + @DisplayName("ADMIN 사용자는 Outbox 지연과 Issue 요약을 조회한다") + void getSummary_returnsSyncSnapshotForAdmin() throws Exception { + LocalDateTime now = LocalDateTime.of(2026, 8, 13, 23, 0); + given(syncAdminQueryService.getSummary()).willReturn(new SyncAdminSummaryResponse( + now, + new SyncEventSummaryResponse(4, 2, 1, 300L, 9, 1, 3, 90.0, UUID.randomUUID(), now), + new SyncIssueSummaryResponse(2, 1, 5, 1), + null + )); + + mockMvc.perform(get("/admin/sync/summary").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.events.pendingCount").value(4)) + .andExpect(jsonPath("$.data.events.oldestPendingAgeSeconds").value(300)) + .andExpect(jsonPath("$.data.events.successRateLast24h").value(90.0)) + .andExpect(jsonPath("$.data.issues.openCount").value(2)); + } + + @Test + @DisplayName("일반 사용자와 미인증 사용자는 Sync 운영 API에 접근할 수 없다") + void getSummary_returnsForbiddenWithoutAdminRole() throws Exception { + mockMvc.perform(get("/admin/sync/summary").with(user("user").roles("USER"))) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/admin/sync/summary")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("ADMIN 수동 Reconciliation은 인증 사용자 ID와 요청 모드를 Service에 전달한다") + void reconcile_runsAuditedRepairBatch() throws Exception { + UUID runId = UUID.randomUUID(); + given(syncAdminCommandService.reconcile(0L, SyncReconciliationMode.REPAIR, 7L)) + .willReturn(new SyncReconciliationAdminResponse(runId, 0, 100, 100, 3, 2, false, null)); + + mockMvc.perform(post("/admin/sync/reconcile") + .with(authentication(adminAuthentication())) + .contentType("application/json") + .content("{\"mode\":\"REPAIR\",\"cursor\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.runId").value(runId.toString())) + .andExpect(jsonPath("$.data.detectedCount").value(3)) + .andExpect(jsonPath("$.data.repairRequestedCount").value(2)); + then(syncAdminCommandService).should().reconcile(0L, SyncReconciliationMode.REPAIR, 7L); + } + + @Test + @DisplayName("Issue 무시 사유가 비어 있으면 400을 반환한다") + void ignoreIssue_rejectsBlankReason() throws Exception { + mockMvc.perform(post("/admin/sync/issues/41/ignore") + .with(authentication(adminAuthentication())) + .contentType("application/json") + .content("{\"reason\":\" \"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-002")); + } + + private UsernamePasswordAuthenticationToken adminAuthentication() { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + "admin@docgrid.io", + null, + java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + ); + authentication.setDetails(7L); + return authentication; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/integration/SyncAdminActionIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/integration/SyncAdminActionIntegrationTest.java new file mode 100644 index 0000000..806d9c2 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/integration/SyncAdminActionIntegrationTest.java @@ -0,0 +1,99 @@ +package com.opensource.docgrid.domain.sync.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminActionResponse; +import com.opensource.docgrid.domain.sync.entity.SyncAdminAction; +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.enums.SyncAdminActionType; +import com.opensource.docgrid.domain.sync.enums.SyncAggregateType; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.repository.SyncAdminActionRepository; +import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; +import com.opensource.docgrid.domain.sync.service.command.SyncAdminCommandService; + +/** + * 실제 PostgreSQL에서 실패 Event 재시도와 관리자 감사 Action이 함께 Commit되는지 검증한다. + */ +@Tag("integration") +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("Sync 관리자 감사 Action 통합 테스트") +class SyncAdminActionIntegrationTest { + + @Autowired private SyncAdminCommandService syncAdminCommandService; + @Autowired private SyncOutboxEventRepository syncOutboxEventRepository; + @Autowired private SyncAdminActionRepository syncAdminActionRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private UUID eventId; + private UUID actionId; + + @AfterEach + void tearDown() { + if (actionId != null) { + syncAdminActionRepository.findByActionId(actionId) + .ifPresent(syncAdminActionRepository::delete); + } + if (eventId != null) { + syncOutboxEventRepository.findByEventId(eventId) + .ifPresent(syncOutboxEventRepository::delete); + } + } + + @Test + @DisplayName("FAILED Event 재시도는 PENDING 전이와 관리자·대상 Event 감사 이력을 함께 남긴다") + void retryEvent_persistsAuditedStateTransition() { + Long adminUserId = jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = 'kcw130502@gmail.com'", + Long.class + ); + SyncOutboxEvent failedEvent = failedEvent(); + eventId = failedEvent.getEventId(); + + SyncAdminActionResponse response = syncAdminCommandService.retryEvent(eventId, adminUserId); + actionId = response.actionId(); + + SyncOutboxEvent retriedEvent = syncOutboxEventRepository.findByEventId(eventId).orElseThrow(); + SyncAdminAction action = syncAdminActionRepository.findByActionId(actionId).orElseThrow(); + assertThat(retriedEvent.getStatus()).isEqualTo(SyncEventStatus.PENDING); + assertThat(action.getActionType()).isEqualTo(SyncAdminActionType.EVENT_RETRIED); + assertThat(action.getTargetId()).isEqualTo(eventId.toString()); + assertThat(action.getAdminUser().getId()).isEqualTo(adminUserId); + } + + private SyncOutboxEvent failedEvent() { + LocalDateTime now = LocalDateTime.now(); + SyncOutboxEvent event = syncOutboxEventRepository.saveAndFlush( + SyncOutboxEvent.builder() + .eventId(UUID.randomUUID()) + .idempotencyKey("admin-action-integration:" + UUID.randomUUID()) + .aggregateType(SyncAggregateType.DOCUMENT_VERSION) + .aggregateId(9_999_999L) + .aggregateVersion(1L) + .eventType(SyncEventType.DOCUMENT_VERSION_CREATED) + .payloadJson("{\"embeddingModelId\":1}") + .availableAt(now.minusMinutes(2)) + .occurredAt(now.minusMinutes(2)) + .maxRetryCount(1) + .build() + ); + UUID claimToken = UUID.randomUUID(); + event.claim("admin-action-test", claimToken, now.minusMinutes(1), now.plusMinutes(1)); + event.markFailed(claimToken, "TEST_FAILURE", "감사 테스트 실패", now); + return syncOutboxEventRepository.saveAndFlush(event); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminCommandServiceTest.java new file mode 100644 index 0000000..8f23594 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncAdminCommandServiceTest.java @@ -0,0 +1,154 @@ +package com.opensource.docgrid.domain.sync.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.sync.converter.SyncAdminConverter; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminActionResponse; +import com.opensource.docgrid.domain.sync.entity.SyncAdminAction; +import com.opensource.docgrid.domain.sync.entity.SyncConsistencyIssue; +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.enums.SyncAggregateType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencySeverity; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.repository.SyncConsistencyIssueRepository; +import com.opensource.docgrid.domain.sync.service.SyncReconciliationOrchestrator; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 관리자 Issue 복구가 행 잠금·repairable 경계·Outbox·감사 Action을 하나의 명령으로 묶는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SyncAdminCommandService 단위 테스트") +class SyncAdminCommandServiceTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 13, 22, 30); + + @Mock private SyncEventManualRetryService syncEventManualRetryService; + @Mock private SyncConsistencyIssueRepository syncConsistencyIssueRepository; + @Mock private SyncEventWriter syncEventWriter; + @Mock private SyncReconciliationOrchestrator syncReconciliationOrchestrator; + @Mock private SyncAdminActionWriter syncAdminActionWriter; + @Mock private SyncAdminConverter syncAdminConverter; + @Mock private SyncAdminAction action; + + private SyncAdminCommandService service; + private DocumentVersion version; + private EmbeddingModel model; + + @BeforeEach + void setUp() { + service = new SyncAdminCommandService( + syncEventManualRetryService, + syncConsistencyIssueRepository, + syncEventWriter, + syncReconciliationOrchestrator, + syncAdminActionWriter, + syncAdminConverter, + Clock.fixed(Instant.parse("2026-08-13T13:30:00Z"), ZoneId.of("Asia/Seoul")) + ); + version = DocumentVersion.builder() + .versionNo(1) + .status(DocumentVersionStatus.UPLOADED) + .build(); + ReflectionTestUtils.setField(version, "id", 11L); + model = EmbeddingModelFixture.createDefaultModel(); + ReflectionTestUtils.setField(model, "id", 7L); + } + + @Test + @DisplayName("복구 가능한 OPEN Issue는 Dispatcher가 처리할 Outbox Event와 연결한다") + void repairIssue_createsAuditedOutboxEvent() { + SyncConsistencyIssue issue = issue(true); + SyncOutboxEvent event = repairEvent(); + given(syncConsistencyIssueRepository.findByIdForUpdate(41L)).willReturn(Optional.of(issue)); + given(syncEventWriter.recordDocumentReindexRequested(any(), any(), any())).willReturn(event); + given(syncAdminActionWriter.record(any(), any(), any(), any(), any(), any())) + .willReturn(action); + given(syncAdminConverter.toActionResponse(action)).willReturn( + new SyncAdminActionResponse(UUID.randomUUID(), null, null, "1", 3L, NOW) + ); + + service.repairIssue(41L, 3L); + + assertThat(issue.getStatus()).isEqualTo(SyncConsistencyIssueStatus.REPAIRING); + assertThat(issue.getRepairEventId()).isEqualTo(event.getEventId()); + then(syncAdminActionWriter).should().record( + 3L, + com.opensource.docgrid.domain.sync.enums.SyncAdminActionType.ISSUE_REPAIR_REQUESTED, + com.opensource.docgrid.domain.sync.enums.SyncAdminTargetType.CONSISTENCY_ISSUE, + "41", + null, + "{\"repairEventId\":\"%s\"}".formatted(event.getEventId()) + ); + } + + @Test + @DisplayName("보고 전용 Issue는 관리자가 강제로 복구할 수 없다") + void repairIssue_rejectsReportOnlyIssue() { + given(syncConsistencyIssueRepository.findByIdForUpdate(41L)) + .willReturn(Optional.of(issue(false))); + + assertThatThrownBy(() -> service.repairIssue(41L, 3L)) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.SYNC_ISSUE_REPAIR_NOT_ALLOWED)); + then(syncEventWriter).shouldHaveNoInteractions(); + } + + private SyncConsistencyIssue issue(boolean repairable) { + SyncConsistencyIssue issue = SyncConsistencyIssue.builder() + .issueKey("MISSING_JOB:VERSION:11:MODEL:7") + .issueType(SyncConsistencyIssueType.MISSING_JOB) + .severity(SyncConsistencySeverity.ERROR) + .documentVersion(version) + .embeddingModel(model) + .expectedJson("{}") + .actualJson("{}") + .repairable(repairable) + .detectedAt(NOW) + .build(); + ReflectionTestUtils.setField(issue, "id", 41L); + return issue; + } + + private SyncOutboxEvent repairEvent() { + return SyncOutboxEvent.builder() + .eventId(UUID.randomUUID()) + .idempotencyKey("admin-repair:41") + .aggregateType(SyncAggregateType.DOCUMENT_VERSION) + .aggregateId(11L) + .aggregateVersion(1L) + .eventType(SyncEventType.DOCUMENT_REINDEX_REQUESTED) + .payloadJson("{\"embeddingModelId\":7}") + .availableAt(NOW) + .occurredAt(NOW) + .maxRetryCount(5) + .build(); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/service/query/SyncAdminQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/query/SyncAdminQueryServiceTest.java new file mode 100644 index 0000000..bf75029 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/query/SyncAdminQueryServiceTest.java @@ -0,0 +1,133 @@ +package com.opensource.docgrid.domain.sync.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.sync.converter.SyncAdminConverter; +import com.opensource.docgrid.domain.sync.dto.response.SyncAdminSummaryResponse; +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.entity.SyncReconciliationRun; +import com.opensource.docgrid.domain.sync.enums.SyncAggregateType; +import com.opensource.docgrid.domain.sync.enums.SyncConsistencyIssueStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.enums.SyncReconciliationMode; +import com.opensource.docgrid.domain.sync.repository.SyncConsistencyIssueRepository; +import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; +import com.opensource.docgrid.domain.sync.repository.SyncReconciliationRunRepository; + +/** + * Sync 관리자 요약이 Queue 지연·24시간 성공률·Issue·마지막 실행 이력을 정확히 조합하는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SyncAdminQueryService 단위 테스트") +class SyncAdminQueryServiceTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 13, 22, 0); + + @Mock private SyncOutboxEventRepository syncOutboxEventRepository; + @Mock private SyncConsistencyIssueRepository syncConsistencyIssueRepository; + @Mock private SyncReconciliationRunRepository syncReconciliationRunRepository; + @Mock private SyncAdminConverter syncAdminConverter; + + private SyncAdminQueryService service; + + @BeforeEach + void setUp() { + service = new SyncAdminQueryService( + syncOutboxEventRepository, + syncConsistencyIssueRepository, + syncReconciliationRunRepository, + syncAdminConverter, + Clock.fixed(Instant.parse("2026-08-13T13:00:00Z"), ZoneId.of("Asia/Seoul")) + ); + } + + @Test + @DisplayName("가장 오래된 PENDING 지연과 처리 성공률 및 마지막 Event ID를 반환한다") + void getSummary_aggregatesOperationalSnapshot() { + LocalDateTime since = NOW.minusHours(24); + SyncOutboxEvent lastProcessed = processedEvent(); + SyncReconciliationRun run = reconciliationRun(); + given(syncOutboxEventRepository.countByStatus(SyncEventStatus.PENDING)).willReturn(4L); + given(syncOutboxEventRepository.countByStatus(SyncEventStatus.PROCESSING)).willReturn(2L); + given(syncOutboxEventRepository.countByStatus(SyncEventStatus.FAILED)).willReturn(1L); + given(syncOutboxEventRepository.findOldestOccurredAtByStatus(SyncEventStatus.PENDING)) + .willReturn(Optional.of(NOW.minusMinutes(5))); + given(syncOutboxEventRepository.countByStatusAndProcessedAtGreaterThanEqual( + SyncEventStatus.PROCESSED, since + )).willReturn(9L); + given(syncOutboxEventRepository.countByStatusAndUpdatedAtGreaterThanEqual( + SyncEventStatus.FAILED, since + )).willReturn(1L); + given(syncOutboxEventRepository.countByUpdatedAtGreaterThanEqualAndRetryCountGreaterThan(since, 0)) + .willReturn(3L); + given(syncOutboxEventRepository.findTopByStatusOrderByProcessedAtDescIdDesc( + SyncEventStatus.PROCESSED + )).willReturn(Optional.of(lastProcessed)); + given(syncConsistencyIssueRepository.countByStatus(SyncConsistencyIssueStatus.OPEN)).willReturn(2L); + given(syncConsistencyIssueRepository.countByStatus(SyncConsistencyIssueStatus.REPAIRING)).willReturn(1L); + given(syncConsistencyIssueRepository + .countByStatusAndRepairEventIdIsNotNullAndResolvedAtGreaterThanEqual( + SyncConsistencyIssueStatus.RESOLVED, since + )).willReturn(5L); + given(syncConsistencyIssueRepository.countFailedRepairIssues()).willReturn(1L); + given(syncReconciliationRunRepository.findTopByOrderByStartedAtDescIdDesc()) + .willReturn(Optional.of(run)); + + SyncAdminSummaryResponse result = service.getSummary(); + + assertThat(result.capturedAt()).isEqualTo(NOW); + assertThat(result.events().oldestPendingAgeSeconds()).isEqualTo(300L); + assertThat(result.events().successRateLast24h()).isEqualTo(90.0); + assertThat(result.events().lastProcessedEventId()).isEqualTo(lastProcessed.getEventId()); + assertThat(result.issues().openCount()).isEqualTo(2L); + assertThat(result.issues().autoResolvedLast24hCount()).isEqualTo(5L); + assertThat(result.reconciliation().runId()).isEqualTo(run.getRunId()); + assertThat(result.reconciliation().detectedCount()).isEqualTo(3); + } + + private SyncOutboxEvent processedEvent() { + SyncOutboxEvent event = SyncOutboxEvent.builder() + .eventId(UUID.randomUUID()) + .idempotencyKey("summary:last-processed") + .aggregateType(SyncAggregateType.DOCUMENT_VERSION) + .aggregateId(11L) + .aggregateVersion(1L) + .eventType(SyncEventType.DOCUMENT_VERSION_CREATED) + .payloadJson("{}") + .availableAt(NOW.minusMinutes(2)) + .occurredAt(NOW.minusMinutes(2)) + .maxRetryCount(5) + .build(); + ReflectionTestUtils.setField(event, "status", SyncEventStatus.PROCESSED); + ReflectionTestUtils.setField(event, "processedAt", NOW.minusMinutes(1)); + return event; + } + + private SyncReconciliationRun reconciliationRun() { + SyncReconciliationRun run = SyncReconciliationRun.builder() + .runId(UUID.randomUUID()) + .mode(SyncReconciliationMode.REPAIR) + .startCursor(0L) + .startedAt(NOW.minusMinutes(3)) + .build(); + run.complete(100L, 100, 3, 2, NOW.minusMinutes(2)); + return run; + } +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..f96ed38 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 diff --git a/frontend/.gitignore b/frontend/.gitignore index 967037f..daf75ac 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -30,6 +30,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/frontend/app/components/PrototypeApp.tsx b/frontend/app/components/PrototypeApp.tsx index a0102de..67706b7 100644 --- a/frontend/app/components/PrototypeApp.tsx +++ b/frontend/app/components/PrototypeApp.tsx @@ -1,9 +1,120 @@ "use client"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; type ModalName = "upload" | "version" | "collection" | "add-document" | "permission" | "token" | "role" | null; +type RagOpsSummary = { + documents: { total: number; searchable: number; pendingIndex: number }; + jobs: { pending: number; processing: number; failed: number; avgProcessMs: number | null }; + workers: { activeCount: number; totalCount: number }; + search: { recent24hCount: number }; +}; + +type SyncSummary = { + capturedAt: string; + events: { + pendingCount: number; + processingCount: number; + failedCount: number; + oldestPendingAgeSeconds: number | null; + processedLast24hCount: number; + failedLast24hCount: number; + retriedLast24hCount: number; + successRateLast24h: number; + lastProcessedEventId: string | null; + lastProcessedAt: string | null; + }; + issues: { + openCount: number; + repairingCount: number; + autoResolvedLast24hCount: number; + failedRepairCount: number; + }; + reconciliation: { + runId: string; + mode: string; + status: string; + scannedCount: number; + detectedCount: number; + repairRequestedCount: number; + startedAt: string; + completedAt: string | null; + } | null; +}; + +type SyncEvent = { + eventId: string; + eventType: string; + aggregateType: string; + aggregateId: number; + status: string; + retryCount: number; + maxRetryCount: number; + occurredAt: string; + lastErrorCode: string | null; +}; + +type SyncIssue = { + issueId: number; + issueType: string; + severity: string; + status: string; + documentId: number | null; + documentVersionId: number | null; + actualJson: string | null; + repairable: boolean; + lastDetectedAt: string; + repairAttemptCount: number; +}; + +type PageData = { content: T[] }; +type ApiEnvelope = { success: boolean; data: T }; + +const API_BASE_URL = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080").replace(/\/$/, ""); + +function accessToken() { + if (typeof window === "undefined") return ""; + return window.sessionStorage.getItem("accessToken") + ?? window.localStorage.getItem("accessToken") + ?? window.localStorage.getItem("docgridAccessToken") + ?? ""; +} + +async function adminRequest(path: string, init?: RequestInit): Promise { + const token = accessToken(); + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init?.headers, + }, + }); + const payload = await response.json().catch(() => null) as ApiEnvelope | { message?: string } | null; + if (!response.ok || !payload || !("data" in payload)) { + const message = payload && "message" in payload ? payload.message : null; + throw new Error(message || `운영 API 요청에 실패했습니다. (${response.status})`); + } + return payload.data; +} + +function formatMetric(value: number | null | undefined) { + return value == null ? "—" : value.toLocaleString("ko-KR"); +} + +function formatAge(seconds: number | null) { + if (seconds == null) return "대기 없음"; + if (seconds < 60) return `${seconds}초`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}분`; + return `${Math.floor(seconds / 3600)}시간 ${Math.floor((seconds % 3600) / 60)}분`; +} + +function formatTimestamp(value: string | null | undefined) { + if (!value) return "—"; + return value.replace("T", " ").slice(5, 16); +} + const navSections = [ { label: "WORKSPACE", @@ -119,10 +230,37 @@ function EmptyState({ symbol, title, description }: { symbol: string; title: str function AuthPage({ mode }: { mode: "login" | "signup" }) { const signup = mode === "signup"; + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [authError, setAuthError] = useState(""); + const [submitting, setSubmitting] = useState(false); - function submit(event: FormEvent) { + async function submit(event: FormEvent) { event.preventDefault(); - window.location.href = "/search"; + if (signup) { + window.location.href = "/search"; + return; + } + setSubmitting(true); + setAuthError(""); + try { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const payload = await response.json() as ApiEnvelope<{ accessToken: string; roles: string[] }> & { message?: string }; + if (!response.ok || !payload.data?.accessToken) { + throw new Error(payload.message || "이메일 또는 비밀번호를 확인하세요."); + } + // 관리자 API와 STOMP CONNECT가 같은 JWT를 재사용하도록 브라우저 Session에 저장한다. + window.sessionStorage.setItem("accessToken", payload.data.accessToken); + window.location.href = payload.data.roles.includes("ADMIN") ? "/admin/dashboard" : "/search"; + } catch (error) { + setAuthError(error instanceof Error ? error.message : "로그인에 실패했습니다."); + } finally { + setSubmitting(false); + } } return ( @@ -143,10 +281,11 @@ function AuthPage({ mode }: { mode: "login" | "signup" }) { {signup ? "CREATE ACCOUNT" : "WELCOME BACK"}

{signup ? "회원가입" : "로그인"}

{signup ? "모든 항목은 필수입니다." : "이메일과 비밀번호를 입력하세요."}

- - + + {signup && <>} - + {authError &&
{authError}
} + {signup ? "이미 계정이 있나요?" : "계정이 없으신가요?"} {signup ? "로그인" : "회원가입"} @@ -173,6 +312,35 @@ export default function PrototypeApp({ initialRoute }: { initialRoute: string }) { id: 49, target: "DOCUMENT_MANAGER", type: "ROLE", permission: "ADMIN", expires: "없음" }, ]); const [users, setUsers] = useState(initialUsers); + const [ragOpsSummary, setRagOpsSummary] = useState(null); + const [syncSummary, setSyncSummary] = useState(null); + const [syncEvents, setSyncEvents] = useState([]); + const [syncIssues, setSyncIssues] = useState([]); + const [syncLoading, setSyncLoading] = useState(false); + const [syncError, setSyncError] = useState(""); + const [syncConnection, setSyncConnection] = useState<"CONNECTING" | "LIVE" | "POLLING">("CONNECTING"); + const [syncBusyKey, setSyncBusyKey] = useState(""); + + const loadSyncDashboard = useCallback(async (showLoading = false) => { + if (showLoading) setSyncLoading(true); + try { + const [dashboard, summary, eventsPage, issuesPage] = await Promise.all([ + adminRequest("/admin/dashboard/summary"), + adminRequest("/admin/sync/summary"), + adminRequest>("/admin/sync/events?size=8"), + adminRequest>("/admin/sync/issues?size=8"), + ]); + setRagOpsSummary(dashboard); + setSyncSummary(summary); + setSyncEvents(eventsPage.content); + setSyncIssues(issuesPage.content); + setSyncError(""); + } catch (error) { + setSyncError(error instanceof Error ? error.message : "운영 데이터를 불러오지 못했습니다."); + } finally { + if (showLoading) setSyncLoading(false); + } + }, []); useEffect(() => { if (!toast) return; @@ -180,6 +348,45 @@ export default function PrototypeApp({ initialRoute }: { initialRoute: string }) return () => window.clearTimeout(timer); }, [toast]); + useEffect(() => { + if (route !== "/admin/dashboard") return; + const initialLoad = window.setTimeout(() => void loadSyncDashboard(true), 0); + const timer = window.setInterval(() => void loadSyncDashboard(), 10_000); + return () => { + window.clearTimeout(initialLoad); + window.clearInterval(timer); + }; + }, [loadSyncDashboard, route]); + + useEffect(() => { + if (route !== "/admin/dashboard") return; + const token = accessToken(); + if (!token) { + const fallback = window.setTimeout(() => setSyncConnection("POLLING"), 0); + return () => window.clearTimeout(fallback); + } + + // 1. 기존 관리자 Dashboard STOMP Topic에 직접 연결해 상태 전이 push를 수신한다. + const socketUrl = `${API_BASE_URL.replace(/^http/, "ws")}/ws/websocket`; + const socket = new WebSocket(socketUrl); + socket.onopen = () => socket.send( + `CONNECT\naccept-version:1.2\nAuthorization:Bearer ${token}\nheart-beat:10000,10000\n\n\0`, + ); + socket.onmessage = (event) => { + const frame = String(event.data); + if (frame.startsWith("CONNECTED")) { + socket.send("SUBSCRIBE\nid:ragops-sync\ndestination:/topic/dashboard\nack:auto\n\n\0"); + setSyncConnection("LIVE"); + return; + } + // 2. 기존 RAGOps push를 신호로 사용하고 민감한 Frame 본문 대신 최신 관리자 API를 다시 읽는다. + if (frame.startsWith("MESSAGE")) void loadSyncDashboard(); + }; + socket.onerror = () => setSyncConnection("POLLING"); + socket.onclose = () => setSyncConnection("POLLING"); + return () => socket.close(); + }, [loadSyncDashboard, route]); + const routeTitle = useMemo(() => { if (route.startsWith("/documents/")) return "문서 상세"; if (route.startsWith("/collections/")) return "컬렉션 상세"; @@ -232,6 +439,38 @@ export default function PrototypeApp({ initialRoute }: { initialRoute: string }) setToast("새 MCP 토큰을 발급했습니다. 원문은 한 번만 표시됩니다."); } + async function runSyncCommand( + busyKey: string, + path: string, + successMessage: string, + body?: unknown, + ) { + setSyncBusyKey(busyKey); + try { + await adminRequest(path, { + method: "POST", + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + setToast(successMessage); + await loadSyncDashboard(); + } catch (error) { + setToast(error instanceof Error ? error.message : "운영 명령 실행에 실패했습니다."); + } finally { + setSyncBusyKey(""); + } + } + + function ignoreSyncIssue(issue: SyncIssue) { + const reason = window.prompt("이 Issue를 자동 조치하지 않는 이유를 입력하세요."); + if (!reason?.trim()) return; + void runSyncCommand( + `ignore-${issue.issueId}`, + `/admin/sync/issues/${issue.issueId}/ignore`, + `Issue #${issue.issueId}를 감사 사유와 함께 무시했습니다.`, + { reason: reason.trim() }, + ); + } + function renderPage() { if (route === "/search") return (
@@ -298,7 +537,84 @@ export default function PrototypeApp({ initialRoute }: { initialRoute: string }) ); if (route === "/admin/dashboard") return ( -
실시간 연결 · /topic/dashboard} />
전체 문서25,368soft-delete 제외
검색 가능21,742INDEXED
인덱싱 대기132UPLOADED · INDEXING
최근 24시간 검색342search.recent24hCount
대기132PENDING
처리 중8PROCESSING
실패27
평균 처리 시간3,200msQueue 대기 제외
정상 Worker5/ 6ACTIVE · IDLE
Worker 1대가 DEAD 상태입니다. docgrid-api-03 · 마지막 heartbeat 6분 전 확인 →
실패 27건이 재시도 한도에 도달했습니다.

최근 실패 Job

클릭하면 Attempt와 Event를 추적할 수 있어요.

전체 보기 →
{jobs.filter((job) => job.status === "FAILED").map((job) =>
#{job.id}{job.document}{job.error}{job.retry}
)}

실패 유형 분포

FAILED 27건

{[["EMBEDDING_PROVIDER_UNAVAILABLE",14],["DOCUMENT_CONTENT_INVALID",7],["STORAGE_UNAVAILABLE",4],["WORKER_INTERNAL_ERROR",2]].map(([name,count]) =>
{name}{count}
)}
+
+ +
{syncConnection === "LIVE" ? "실시간 연결" : syncConnection === "CONNECTING" ? "연결 중" : "10초 자동 갱신"}
+ + + } + /> + + {syncError &&
운영 API를 연결할 수 없습니다. {syncError} ADMIN JWT를 브라우저의 accessToken에 저장했는지 확인하세요.
} + +
+
전체 문서{formatMetric(ragOpsSummary?.documents.total)}soft-delete 제외
+
검색 가능{formatMetric(ragOpsSummary?.documents.searchable)}INDEXED
+
인덱싱 대기{formatMetric(ragOpsSummary?.documents.pendingIndex)}UPLOADED · INDEXING
+
최근 24시간 검색{formatMetric(ragOpsSummary?.search.recent24hCount)}실제 Query 집계
+
+
+
Job 대기{formatMetric(ragOpsSummary?.jobs.pending)}PENDING
+
Job 처리 중{formatMetric(ragOpsSummary?.jobs.processing)}PROCESSING
+
Job 실패{formatMetric(ragOpsSummary?.jobs.failed)}실패 Job 확인 →
+
평균 처리 시간{formatMetric(ragOpsSummary?.jobs.avgProcessMs)}msQueue 대기 제외
+
정상 Worker{formatMetric(ragOpsSummary?.workers.activeCount)}/ {formatMetric(ragOpsSummary?.workers.totalCount)}ACTIVE · IDLE
+
+ +
+
TRANSACTIONAL OUTBOX

동기화 원장과 정합성

+

마지막 갱신 {formatTimestamp(syncSummary?.capturedAt)} · 마지막 처리 Event {syncSummary?.events.lastProcessedEventId?.slice(0, 8) ?? "—"}

+
+
+
Outbox 대기{formatMetric(syncSummary?.events.pendingCount)}최대 지연 {formatAge(syncSummary?.events.oldestPendingAgeSeconds ?? null)}
+
Dispatcher 처리 중{formatMetric(syncSummary?.events.processingCount)}Lease 소유 Event
+
0 ? "danger-metric" : "success-metric"}`}>Event 최종 실패{formatMetric(syncSummary?.events.failedCount)}최근 24시간 {formatMetric(syncSummary?.events.failedLast24hCount)}
+
24시간 처리 성공률{syncSummary ? `${syncSummary.events.successRateLast24h}%` : "—"}성공 {formatMetric(syncSummary?.events.processedLast24hCount)} · 재시도 {formatMetric(syncSummary?.events.retriedLast24hCount)}
+
0 ? "danger-metric" : "success-metric"}`}>미해결 정합성 Issue{formatMetric(syncSummary?.issues.openCount)}복구 중 {formatMetric(syncSummary?.issues.repairingCount)}
+
24시간 자동 복구{formatMetric(syncSummary?.issues.autoResolvedLast24hCount)}실패 {formatMetric(syncSummary?.issues.failedRepairCount)}
+
마지막 Reconciliation{syncSummary?.reconciliation?.status ?? "미실행"}{syncSummary?.reconciliation ? `${syncSummary.reconciliation.scannedCount}개 검사 · ${syncSummary.reconciliation.detectedCount}개 탐지` : "실행 이력 없음"}
+
복구 요청{formatMetric(syncSummary?.reconciliation?.repairRequestedCount)}{syncSummary?.reconciliation?.mode ?? "DRY_RUN / REPAIR"}
+
+ + {((syncSummary?.events.failedCount ?? 0) > 0 || (syncSummary?.issues.failedRepairCount ?? 0) > 0) &&
+ {(syncSummary?.events.failedCount ?? 0) > 0 &&
최종 실패 Sync Event가 {syncSummary?.events.failedCount}건 있습니다. 원인을 확인한 뒤 개별 재시도하세요.
} + {(syncSummary?.issues.failedRepairCount ?? 0) > 0 &&
자동 복구에 실패한 Issue가 {syncSummary?.issues.failedRepairCount}건 있습니다.
} +
} + +
+
+

최근 Sync Event

Event ID로 장애 전후 처리 지점을 추적합니다.

GET /admin/sync/events
+
+ {syncEvents.length === 0 ? : syncEvents.map((event) =>
+ {event.eventId.slice(0, 8)} + {event.eventType}{event.aggregateType} #{event.aggregateId} + + {event.retryCount} / {event.maxRetryCount} + {event.lastErrorCode ?? formatTimestamp(event.occurredAt)} + {event.status === "FAILED" ? : } +
)} +
+
+
+

정합성 Issue

위험한 변경은 보고만 하고 관리자 판단을 기다립니다.

GET /admin/sync/issues
+
+ {syncIssues.length === 0 ? : syncIssues.map((issue) =>
+ #{issue.issueId} · {issue.issueType}document {issue.documentId ?? "GLOBAL"} · {formatTimestamp(issue.lastDetectedAt)} + + + + {issue.status === "OPEN" && issue.repairable && } + {issue.status === "OPEN" && } + +
)} +
+
+
+
); if (route === "/admin/indexing-jobs") return ( diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 3eeae32..740f9f0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -52,12 +52,14 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .auth-brand-panel ul { margin: 0; padding: 0; display: grid; gap: 12px; list-style: none; color: rgba(255,255,255,.74); font-size: 11px; } .auth-brand-panel li::before { content: "✓"; margin-right: 9px; color: #bdf2df; font-weight: 800; } .auth-form-panel { display: grid; place-items: center; padding: 70px 30px; } +.auth-error { padding: 10px 12px; color: #9f3338; background: #fff0f0; border: 1px solid #f3babc; border-radius: 8px; font-size: 9px; line-height: 1.5; } .auth-form { width: min(420px, 100%); padding: 32px; background: white; border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); } .auth-form h2 { margin: 10px 0 6px; font-size: 26px; letter-spacing: -.8px; } .auth-form > p { margin: 0 0 25px; color: var(--muted); font-size: 11px; } .auth-form label { margin-top: 15px; display: flex; flex-direction: column; gap: 7px; color: #555666; font-size: 10px; font-weight: 750; } .auth-form input, .auth-form select { width: 100%; height: 43px; padding: 0 12px; color: var(--ink-soft); background: white; border: 1px solid #dcdbe4; border-radius: 9px; outline: 0; font-size: 11px; } .auth-submit { width: 100%; margin-top: 24px; } +.auth-submit:disabled { opacity: .65; cursor: wait; } .auth-switch { margin-top: 15px; display: block; color: #91919f; text-align: center; font-size: 10px; } .auth-switch a { color: var(--violet); font-weight: 800; } .auth-mobile-brand { display: none; } @@ -337,6 +339,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .metric-card b em { margin-left: 4px; color: #777888; font-family: inherit; font-size: 11px; font-style: normal; } .metric-card small { color: #9899a6; font-size: 7px; } .metric-card button { width: fit-content; padding: 5px 7px; color: white; background: var(--violet); border: 0; border-radius: 6px; font-size: 7px; } +.metric-card > a { width: fit-content; color: var(--violet); font-size: 7px; font-weight: 750; } .success-metric > b { color: #13816a; } .danger-metric > b { color: var(--red); } .job-metrics { grid-template-columns: repeat(5,1fr); } @@ -346,6 +349,28 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .warning-alert { color: #7c5523; background: #fff3df; border-color: #efce99; } .dashboard-grid { max-width: 1280px; margin: 0 auto; display: grid; grid-template-columns: 1.35fr .65fr; gap: 12px; } .dashboard-grid .panel-card { margin: 0; } +.sync-page-actions { display: flex; align-items: center; gap: 7px; } +.sync-page-actions button:disabled, .sync-issue-actions button:disabled, .sync-event-table button:disabled { opacity: .55; cursor: wait; } +.connection-polling { color: #7c5523; background: #fff3df; border-color: #efce99; } +.connection-polling span { background: #d89435; box-shadow: 0 0 0 4px rgba(216,148,53,.12); } +.sync-api-error { max-width: 1280px; margin: 0 auto 12px; } +.sync-api-error small { display: block; margin-top: 3px; color: #a45d60; } +.sync-section-heading { max-width: 1280px; margin: 26px auto 12px; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; } +.sync-section-heading h2 { margin: 4px 0 0; font-family: Georgia, "Times New Roman", serif; font-size: 21px; } +.sync-section-heading p { margin: 0; color: #8d8e9c; font-size: 8px; } +.sync-metrics { grid-template-columns: repeat(4,1fr); } +.metric-card > .metric-status { font-family: inherit; font-size: 18px; } +.sync-operations-grid { grid-template-columns: 1.1fr .9fr; } +.sync-event-table > div { grid-template-columns: .65fr 1.45fr .72fr .45fr 1fr .55fr; } +.sync-event-table code { color: #5f55c9; font-size: 7px; } +.sync-event-table span, .sync-issue-table span { min-width: 0; } +.sync-event-table span > strong, .sync-issue-table span > strong { display: block; overflow: hidden; color: #4b4c5c; text-overflow: ellipsis; white-space: nowrap; font-size: 8px; } +.sync-event-table span > small, .sync-issue-table span > small { display: block; margin-top: 3px; overflow: hidden; color: #999aa7; text-overflow: ellipsis; white-space: nowrap; font-size: 7px; } +.sync-issue-table > div { grid-template-columns: minmax(150px,1.5fr) .55fr .62fr .7fr; } +.sync-issue-actions { display: flex; justify-content: flex-end; gap: 5px; } +.sync-event-table .empty-state, .sync-issue-table .empty-state { min-height: 160px; display: flex; } +.sync-event-table .empty-state > span, .sync-issue-table .empty-state > span { font-size: 24px; } +.sync-event-table .empty-state strong, .sync-issue-table .empty-state strong { overflow: visible; font-size: 10px; } .failed-jobs > div { grid-template-columns: .4fr 1.1fr 1.5fr .4fr .55fr; } .failed-jobs code { overflow: hidden; color: var(--red); text-overflow: ellipsis; white-space: nowrap; font-size: 7px; } .failed-jobs button { padding: 5px 7px; color: white; background: var(--violet); border: 0; border-radius: 6px; font-size: 7px; } @@ -441,6 +466,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .detail-grid.three, .job-detail-grid { grid-template-columns: 1fr 1fr; } .detail-grid.three > :last-child, .job-detail-grid > :last-child { grid-column: 1 / -1; } .dashboard-grid, .users-layout { grid-template-columns: 1fr; } + .sync-metrics { grid-template-columns: repeat(2,1fr); } } @media (max-width: 850px) { @@ -464,6 +490,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .answer-card { padding: 20px; gap: 12px; } .page-heading { align-items: flex-start; } .page-actions { flex-wrap: wrap; justify-content: flex-end; } + .sync-page-actions { flex-wrap: wrap; justify-content: flex-end; } .metric-grid { grid-template-columns: repeat(2,1fr); } .job-metrics { grid-template-columns: repeat(2,1fr); } .alert-row, .permission-layout, .token-grid, .account-layout { grid-template-columns: 1fr; } @@ -498,6 +525,8 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .collection-summary > div:last-child { flex-wrap: wrap; justify-content: flex-end; } .stepper { overflow-x: auto; grid-template-columns: repeat(5,110px); padding-bottom: 8px; } .metric-grid, .job-metrics, .worker-metrics { grid-template-columns: 1fr; } + .sync-section-heading { align-items: flex-start; flex-direction: column; } + .sync-page-actions { justify-content: flex-start; } .token-reveal, .token-reveal > div { align-items: stretch; flex-direction: column; } .account-info { grid-template-columns: 1fr; } .role-map > div { grid-template-columns: 1fr; gap: 7px; padding: 10px 0; } diff --git a/frontend/tests/rendered-html.test.mjs b/frontend/tests/rendered-html.test.mjs index 8dfadc6..a1eb9ac 100644 --- a/frontend/tests/rendered-html.test.mjs +++ b/frontend/tests/rendered-html.test.mjs @@ -50,11 +50,12 @@ test("server-renders every prototype route", async () => { }); test("renders navigation and detailed feature content", async () => { - const [home, login, document, job] = await Promise.all([ + const [home, login, document, job, dashboard] = await Promise.all([ render("/"), render("/login"), render("/documents/1024"), render("/admin/indexing-jobs/4402"), + render("/admin/dashboard"), ]); const homeHtml = await home.text(); @@ -67,5 +68,10 @@ test("renders navigation and detailed feature content", async () => { assert.match(await login.text(), /로그인 없이 둘러보기/); assert.match(await document.text(), /인덱싱 진행 상태/); assert.match(await job.text(), /이벤트 타임라인/); + const dashboardHtml = await dashboard.text(); + assert.match(dashboardHtml, /SYNC CONTROL PLANE/); + assert.match(dashboardHtml, /동기화 원장과 정합성/); + assert.match(dashboardHtml, /정합성 검사/); + assert.doesNotMatch(dashboardHtml, /25,368|21,742/); assert.doesNotMatch(homeHtml, /codex-preview|Your site is taking shape|react-loading-skeleton/i); });