From 8adfa14ba32efad68ae1b81931b5e14f9816c93b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:35:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EC=82=90=EB=B4=87=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20API=EC=9D=98=20=EC=8A=AC=EB=9E=99=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DiningReportDeliveryProperties.java | 22 - .../dining/controller/DiningReportBotApi.java | 93 +-- .../controller/DiningReportBotController.java | 9 +- .../dto/DiningReportDeliveryResponse.java | 29 +- .../DiningReportDeliveryResultRequest.java | 185 +----- .../DiningReportDeliveryResultResponse.java | 6 +- .../dining/model/DiningReportChange.java | 65 +- .../dining/model/DiningReportDelivery.java | 132 ---- .../model/DiningReportDeliveryAttempt.java | 107 ---- .../DiningReportDeliveryFailureReason.java | 7 - .../model/DiningReportDeliveryMode.java | 6 - .../model/DiningReportDeliveryOperation.java | 6 - .../model/DiningReportDeliveryOutcome.java | 3 +- .../model/DiningReportDeliveryStatus.java | 4 +- .../model/DiningReportDeliveryTarget.java | 115 ---- .../dining/model/DiningReportSequence.java | 15 - .../DiningReportChangeRepository.java | 26 +- ...DiningReportDeliveryAttemptRepository.java | 20 - .../DiningReportDeliveryRepository.java | 15 - .../DiningReportDeliveryTargetRepository.java | 27 - .../service/DiningReportChangeService.java | 17 +- .../service/DiningReportDeliveryService.java | 341 ++--------- .../koin/global/code/ApiResponseCode.java | 4 +- .../V14__decouple_dining_report_delivery.sql | 45 ++ .../domain/DiningReportDeliveryApiTest.java | 573 +++++++++--------- .../domain/DiningSoldOutReportApiTest.java | 96 +-- .../DiningSoldOutReportConcurrencyTest.java | 39 +- .../DiningSoldOutReportMigrationTest.java | 242 +++++--- 28 files changed, 725 insertions(+), 1524 deletions(-) delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/config/DiningReportDeliveryProperties.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDelivery.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryAttempt.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryFailureReason.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryMode.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOperation.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryTarget.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryAttemptRepository.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryRepository.java delete mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryTargetRepository.java create mode 100644 src/main/resources/db/migration/V14__decouple_dining_report_delivery.sql diff --git a/src/main/java/in/koreatech/koin/domain/dining/config/DiningReportDeliveryProperties.java b/src/main/java/in/koreatech/koin/domain/dining/config/DiningReportDeliveryProperties.java deleted file mode 100644 index 6a5284c92..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/config/DiningReportDeliveryProperties.java +++ /dev/null @@ -1,22 +0,0 @@ -package in.koreatech.koin.domain.dining.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "dining.report.delivery") -public class DiningReportDeliveryProperties { - - private String workspaceId; - private String channelId; - - public boolean isConfigured() { - return StringUtils.hasText(workspaceId) && StringUtils.hasText(channelId); - } -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotApi.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotApi.java index 7b2f57546..3cd413c7f 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotApi.java +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotApi.java @@ -13,9 +13,7 @@ import static in.koreatech.koin.global.code.ApiResponseCode.OK; import static in.koreatech.koin.global.code.ApiResponseCode.OPTIMISTIC_LOCKING_FAILURE; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; +import java.util.Set; import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springframework.http.ResponseEntity; @@ -42,8 +40,6 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.StringSchema; import jakarta.validation.Valid; @Tag(name = "(Bot) Dining: 식단 품절 제보") @@ -51,23 +47,18 @@ public interface DiningReportBotApi { @ApiResponseCodes({OK, NO_CONTENT, BOT_AUTHENTICATION_FAILED}) - @ApiResponse(responseCode = "200", description = "실행할 전송 작업 한 건", + @ApiResponse(responseCode = "200", description = "실행할 작업 한 건", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiningReportDeliveryResponse.class))) @ApiResponse(responseCode = "204", description = "수행할 작업이 없으면 본문 없이 반환합니다.", content = @Content, headers = @Header(name = "Retry-After", description = "다음 조회까지 기다릴 시간(초)", schema = @Schema(type = "integer", allowableValues = "5", example = "5"))) - @Operation(operationId = "claimDiningReportDelivery", summary = "전송 작업 한 건 가져오기", description = """ - - 인증 헤더를 넣고 본문 없이 호출하면 지금 수행할 수 있는 작업 한 건을 배정합니다. 요청마다 새 작업을 조회합니다. + @Operation(operationId = "claimDiningReportDelivery", summary = "제보 변경 작업 한 건 가져오기", description = """ + - 서비스 토큰으로 인증하며 본문 없이 호출합니다. 봇 요청에는 Idempotency-Key가 필요하지 않습니다. - 작업이 있으면 200을, 없으면 본문 없는 204와 Retry-After: 5를 반환합니다. 5초 뒤 다시 조회합니다. - - SEND일 때만 생성이나 수정을 시도별 최대 한 번 호출합니다. VERIFY는 기존 메시지 확인만 수행합니다. - - expires_at은 배정 후 60초가 되는 시각입니다. 이후 생성이나 수정 요청을 새로 시작하거나 반복하지 않습니다. - - 최초 CREATE는 저장이 완료된 최신 제보 상태를 사용합니다. 이미 처리된 제보라도 메시지가 없으면 CREATE입니다. - - 배정받은 report, operation, target은 바꾸지 않습니다. 이후 변경은 성공 확인 뒤 새 UPDATE로 반영합니다. - - UPDATE는 저장된 워크스페이스와 채널, message_ts를 그대로 사용합니다. 기존 메시지를 CREATE로 대체하지 않습니다. - - 첫 블록은 section이며 block_id에 koin_dining_delivery:를 넣고 전체 내용과 한 번에 전송합니다. - - VERIFY는 대상, 신뢰할 수 있는 삐봇 작성자 ID, 표식과 내용을 모두 확인하며 UPDATE는 message_ts도 비교합니다. - - 여러 메시지가 일치하거나 조회 실패, 기한 만료, 요청 제한이나 접근 문제로 확인하지 못하면 UNCERTAIN을 통보합니다. - - claim 응답을 잃었다면 다음 요청이 같은 작업을 반환한다고 보장하지 않습니다. 기존 시도의 복구 절차를 따릅니다. + - report는 해당 변경의 고정된 내용입니다. 같은 제보의 이전 작업이 완료되어야 다음 변경을 배정합니다. + - expires_at은 배정 후 60초가 되는 한국시간이며, 이 시각부터 같은 작업을 다시 배정할 수 있습니다. + - 재배정 시 delivery_id는 유지되고 attempt_token은 새로 발급됩니다. 삐봇은 delivery_id로 중복 처리를 관리합니다. + - 전송 대상과 메시지 정보, 생성과 수정, 확인은 삐봇이 관리합니다. 반복 배정에 따른 중복 전송 방지도 삐봇에서 처리합니다. """, parameters = @Parameter(name = "X-Koin-Service-Token", in = ParameterIn.HEADER, required = true, description = "발급받은 서비스 토큰 원문. Bearer는 붙이지 않습니다.", schema = @Schema(type = "string"))) @PostMapping("/internal/dining/soldout-reports/deliveries/claim") @@ -77,17 +68,14 @@ public interface DiningReportBotApi { OK, ILLEGAL_ARGUMENT, INVALID_REQUEST_BODY, NOT_READABLE_HTTP_MESSAGE, BOT_AUTHENTICATION_FAILED, NOT_FOUND_DINING_REPORT_DELIVERY, DINING_REPORT_DELIVERY_CONFLICT }) - @Operation(operationId = "reportDiningReportDeliveryResult", summary = "전송 작업 결과 통보", description = """ - - deliveryId와 해당 시도의 attempt_token으로 결과를 통보합니다. 기한이 지난 결과도 확인합니다. - - SUCCEEDED에는 실제 channel과 문자열 ts를 message_ref로 전달합니다. UPDATE는 기존 메시지 연결과 같아야 합니다. - - SEND가 반영되지 않았음이 확실한 경우만 NOT_APPLIED입니다. VERIFY에서는 사용할 수 없습니다. - - NOT_SENT는 요청 미호출, RATE_LIMITED는 1 이상의 정수 retry_after_seconds, - REJECTED는 1~128자의 error_code를 전달합니다. 각 형식에 없는 필드는 null이어도 전달하지 않습니다. - - 반영 여부를 알 수 없거나 VERIFY로 확인하지 못했다면 UNCERTAIN을 통보하며 메시지를 다시 쓰지 않습니다. - - 같은 결과와 메시지 정보를 재통보하면 중복 완료 없이 200과 현재 delivery_state를 반환합니다. - - 늦게 도착한 확실한 SEND 결과는 유효한 VERIFY를 종료하며 이후 오래된 실패나 만료가 결과를 되돌리지 않습니다. - - 이미 확인한 결과와 다른 결과나 메시지 연결은 409입니다. 기존 연결을 보존하고 추가 전송을 보류합니다. - - 작업이 없거나 해당 작업의 attempt_token이 아니면 404를 반환합니다. + @Operation(operationId = "reportDiningReportDeliveryResult", summary = "작업 결과 통보", description = """ + - 본문에는 attempt_token과 outcome(SUCCEEDED 또는 FAILED)만 전달합니다. 다른 필드는 null이어도 거부합니다. + - SUCCEEDED는 해당 변경 작업을 완료합니다. 제보 승인이나 반려와 별개이며 백엔드가 외부 전송을 확인한 뜻은 아닙니다. + - FAILED는 작업을 대기 상태로 돌리고 5초 뒤부터 다시 배정할 수 있게 합니다. + - 현재 토큰은 expires_at이 지나도 재배정 전까지 결과를 접수할 수 있습니다. + - 마지막으로 접수한 동일 토큰과 결과의 재통보는 200과 현재 delivery_state를 반환합니다. + - 재배정된 이전 토큰, 현재 시도와 다른 토큰, 이미 접수한 결과와 반대인 결과는 409를 반환합니다. + - 작업이 없거나 토큰이 다른 작업에 속하는 것으로 확인되면 404를 반환합니다. """, parameters = @Parameter(name = "X-Koin-Service-Token", in = ParameterIn.HEADER, required = true, description = "발급받은 서비스 토큰 원문. Bearer는 붙이지 않습니다.", schema = @Schema(type = "string"))) @PostMapping("/internal/dining/soldout-reports/deliveries/{deliveryId}/result") @@ -108,8 +96,8 @@ ResponseEntity reportDiningReportDeliveryRes - 대상에 처리자를 기록하고 자동 승인된 제보는 대상의 source_report_id를 기록합니다. - 이미 승인된 제보를 재호출하면 최초 처리 결과·처리자·시각·처리 묶음을 유지합니다. 이미 반려되었다면 409를 반환합니다. - 영양사가 먼저 품절 처리하면 해당 식단의 PENDING 제보는 즉시 자동 반려됩니다. - - affected_report_ids로 함께 처리한 제보를 확인하고 다음 전송 작업을 가져옵니다. 승인 응답으로 슬랙을 직접 수정하지 않습니다. - - already_processed는 업무 처리 재호출 여부이며 슬랙 반영 완료를 뜻하지 않습니다. + - affected_report_ids로 함께 처리한 제보를 확인합니다. 변경 내용은 다음 작업으로 배정됩니다. + - already_processed는 업무 처리 재호출 여부입니다. """) @PostMapping("/internal/dining/soldout-reports/{reportId}/approve") ResponseEntity approveReport( @@ -127,7 +115,7 @@ ResponseEntity approveReport( - 대상 PENDING 제보만 반려하고 다른 제보와 식단의 품절 상태를 유지합니다. - 이미 반려된 제보를 재호출하면 최초 처리 결과·처리자·시각·처리 묶음을 유지합니다. 이미 승인되었다면 409를 반환합니다. - 영양사가 먼저 품절 처리한 제보도 기존 COOP_PREPROCESSED, REJECTED 결과를 반환합니다. - - 슬랙 메시지는 다음 전송 작업으로 반영합니다. 반려 응답으로 메시지를 직접 수정하거나 전송 완료로 판단하지 않습니다. + - 변경 내용은 다음 작업으로 배정됩니다. """) @PostMapping("/internal/dining/soldout-reports/{reportId}/reject") ResponseEntity rejectReport( @@ -139,7 +127,7 @@ ResponseEntity rejectReport( @Operation(summary = "삐봇 식단 품절 제보 상세 조회", description = """ - 제보의 식단과 사진, 현재 처리 결과를 조회합니다. 제보자 정보는 반환하지 않습니다. - - 슬랙 메시지는 배정받은 report로 작성하며 상세 조회 결과로 작업 내용을 바꾸거나 직접 메시지를 수정하지 않습니다. + - 작업에는 배정받은 report를 사용하며 상세 조회 결과로 고정된 내용을 대체하지 않습니다. - 수동 처리에는 processor와 processed_at이 있고 대기 중이거나 자동 처리된 경우에는 null입니다. 자동 처리 시각은 updated_at입니다. """) @GetMapping("/internal/dining/soldout-reports/{reportId}") @@ -156,48 +144,13 @@ public void customise(OpenAPI openApi) { return; } // 기존 Swagger 생성기는 클래스의 additionalProperties=false 어노테이션을 누락합니다. + Set closedObjects = Set.of("DiningReportDeliveryResponse", "DiningReportDeliveryResultRequest", + "DiningReportDeliveryResultResponse"); openApi.getComponents().getSchemas().forEach((name, schema) -> { - if (name.startsWith("DiningReportDelivery") - && ("object".equals(schema.getType()) || schema.getOneOf() != null)) { + if (closedObjects.contains(name) && "object".equals(schema.getType())) { schema.setAdditionalProperties(false); } }); - io.swagger.v3.oas.models.media.Schema claim = - openApi.getComponents().getSchemas().get("DiningReportDeliveryResponse"); - io.swagger.v3.oas.models.media.Schema target = - openApi.getComponents().getSchemas().get("DiningReportDeliveryMessageTarget"); - if (claim != null && target != null) { - claim.setOneOf(List.of(claimVariant(claim, target, "CREATE"), claimVariant(claim, target, "UPDATE"))); - } - } - - private ObjectSchema claimVariant( - io.swagger.v3.oas.models.media.Schema claim, - io.swagger.v3.oas.models.media.Schema target, - String operation - ) { - StringSchema messageTs = new StringSchema(); - if ("CREATE".equals(operation)) { - messageTs.setNullable(true); - messageTs.setEnum(Collections.singletonList(null)); - } else { - messageTs.setMinLength(1); - } - - ObjectSchema targetVariant = new ObjectSchema(); - targetVariant.setProperties(new LinkedHashMap<>(target.getProperties())); - targetVariant.setRequired(target.getRequired()); - targetVariant.setAdditionalProperties(false); - targetVariant.addProperty("message_ts", messageTs); - - ObjectSchema variant = new ObjectSchema(); - variant.setTitle(operation); - variant.setProperties(new LinkedHashMap<>(claim.getProperties())); - variant.setRequired(claim.getRequired()); - variant.setAdditionalProperties(false); - variant.addProperty("operation", new StringSchema()._enum(List.of(operation))); - variant.addProperty("target", targetVariant); - return variant; } } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotController.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotController.java index 0fbcf23c2..48a4ccaa6 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotController.java +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotController.java @@ -1,6 +1,5 @@ package in.koreatech.koin.domain.dining.controller; -import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_DELIVERY_CONFLICT; import static in.koreatech.koin.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; import java.util.UUID; @@ -48,13 +47,7 @@ public ResponseEntity reportDiningReportDeli if (!deliveryId.matches(DiningReportDeliveryResultRequest.UUID_PATTERN)) { throw CustomException.of(ILLEGAL_ARGUMENT); } - DiningReportDeliveryService.Result result = diningReportDeliveryService.recordResult( - UUID.fromString(deliveryId), request); - // 서비스 트랜잭션이 충돌 보류 상태를 커밋한 뒤 HTTP 예외를 발생시킵니다. - if (result.conflict()) { - throw CustomException.of(DINING_REPORT_DELIVERY_CONFLICT); - } - return ResponseEntity.ok(result.response()); + return ResponseEntity.ok(diningReportDeliveryService.recordResult(UUID.fromString(deliveryId), request)); } @PostMapping("/internal/dining/soldout-reports/{reportId}/approve") diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResponse.java index b61cc4f49..afcc5e17d 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResponse.java @@ -11,43 +11,22 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryMode; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryOperation; import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(SnakeCaseStrategy.class) @JsonInclude(JsonInclude.Include.ALWAYS) -@Schema(description = "배정된 전송 작업 한 건. report, operation, target은 배정 시점의 값으로 고정됩니다.", +@Schema(description = "배정된 작업 한 건. report는 변경 시점의 내용으로 고정됩니다.", additionalProperties = Schema.AdditionalPropertiesValue.FALSE) public record DiningReportDeliveryResponse( - @Schema(description = "전송 작업과 메시지 내용을 구분하는 UUID", format = "uuid", pattern = UUID_PATTERN, requiredMode = REQUIRED) + @Schema(description = "재배정되어도 유지되는 작업 UUID", format = "uuid", pattern = UUID_PATTERN, requiredMode = REQUIRED) UUID deliveryId, @Schema(description = "이번 시도의 비공개 토큰. 결과 통보에만 사용합니다.", format = "uuid", pattern = UUID_PATTERN, requiredMode = REQUIRED) UUID attemptToken, - @Schema(description = "SEND는 생성이나 수정, VERIFY는 기존 메시지 확인만 수행합니다.", requiredMode = REQUIRED) - DiningReportDeliveryMode mode, - @Schema(description = "CREATE는 새 메시지 생성, UPDATE는 연결된 메시지 수정입니다.", requiredMode = REQUIRED) - DiningReportDeliveryOperation operation, - @Schema(description = "배정 후 60초가 되는 한국시간. 이후 생성이나 수정 요청을 새로 시작하지 않습니다.", + @Schema(description = "배정 후 60초가 되는 한국시간. 이 시각부터 작업을 다시 배정할 수 있습니다.", type = "string", format = "date-time", example = "2026-10-06T12:42:00+09:00", requiredMode = REQUIRED) @JsonFormat(shape = JsonFormat.Shape.STRING) OffsetDateTime expiresAt, - @Schema(description = "백엔드에 저장된 메시지 대상", requiredMode = REQUIRED) - Target target, - @Schema(description = "배정 시점의 제보 내용. 상세 조회 결과로 대체하지 않습니다.", requiredMode = REQUIRED) + @Schema(description = "해당 변경의 제보 내용. 상세 조회 결과로 대체하지 않습니다.", requiredMode = REQUIRED) DiningReportResponse report ) { - @JsonNaming(SnakeCaseStrategy.class) - @JsonInclude(JsonInclude.Include.ALWAYS) - @Schema(name = "DiningReportDeliveryMessageTarget", additionalProperties = Schema.AdditionalPropertiesValue.FALSE) - public record Target( - @Schema(description = "전송 대상 워크스페이스", minLength = 1, requiredMode = REQUIRED) - String workspaceId, - @Schema(description = "전송 대상 채널. 스레드로 보내지 않습니다.", minLength = 1, requiredMode = REQUIRED) - String channelId, - @Schema(description = "CREATE이면 null, UPDATE이면 저장된 슬랙 ts 문자열입니다. 숫자로 변환하지 않습니다.", - nullable = true, requiredMode = REQUIRED) - String messageTs - ) { - } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultRequest.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultRequest.java index 354774829..06fbac7b8 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultRequest.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultRequest.java @@ -5,127 +5,56 @@ import static in.koreatech.koin.global.code.ApiResponseCode.NOT_READABLE_HTTP_MESSAGE; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import java.util.Iterator; import java.util.Set; import java.util.UUID; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryFailureReason; import in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome; import in.koreatech.koin.global.exception.CustomException; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; @JsonNaming(SnakeCaseStrategy.class) -@JsonInclude(JsonInclude.Include.NON_NULL) -@Schema(description = "결과에 맞는 본문 하나만 전달합니다. NOT_APPLIED는 SEND에서만 허용됩니다. " - + "각 형식에 없는 필드는 null이어도 전달할 수 없습니다.", - oneOf = { - DiningReportDeliveryResultRequest.Succeeded.class, - DiningReportDeliveryResultRequest.NotSent.class, - DiningReportDeliveryResultRequest.RateLimited.class, - DiningReportDeliveryResultRequest.Rejected.class, - DiningReportDeliveryResultRequest.Uncertain.class - }, additionalProperties = Schema.AdditionalPropertiesValue.FALSE) +@Schema(description = "배정받은 시도의 토큰과 결과만 전달합니다. 다른 필드는 null이어도 허용하지 않습니다.", + additionalProperties = Schema.AdditionalPropertiesValue.FALSE) public record DiningReportDeliveryResultRequest( - @Schema(description = "작업 배정에서 받은 이번 시도의 비공개 토큰", format = "uuid", requiredMode = REQUIRED) + @Schema(description = "작업 배정에서 받은 이번 시도의 비공개 토큰", format = "uuid", + pattern = DiningReportDeliveryResultRequest.UUID_PATTERN, + requiredMode = REQUIRED) @NotNull UUID attemptToken, - @Schema(description = "삐봇이 확인한 전송 결과", requiredMode = REQUIRED) - @NotNull DiningReportDeliveryOutcome outcome, - @Schema(description = "SUCCEEDED일 때만 필수이며, 그 외에는 전달하지 않습니다.") - @Valid MessageReference messageRef, - @Schema(description = "NOT_APPLIED일 때만 필수이며, 그 외에는 전달하지 않습니다.") - DiningReportDeliveryFailureReason reason, - @Schema(description = "RATE_LIMITED일 때만 필수인 슬랙 Retry-After 초", minimum = "1") - @Min(1) Long retryAfterSeconds, - @Schema(description = "REJECTED일 때만 필수인 슬랙 오류 코드", minLength = 1, maxLength = 128) - String errorCode + @Schema(description = "삐봇이 통보하는 작업 결과", requiredMode = REQUIRED) + @NotNull DiningReportDeliveryOutcome outcome ) { public static final String UUID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + private static final Set FIELDS = Set.of("attempt_token", "outcome"); @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public static DiningReportDeliveryResultRequest fromJson(JsonNode body) { - requireObject(body); - String token = requiredText(body, "attempt_token"); - if (!token.matches(UUID_PATTERN)) { - throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); - } - UUID attemptToken = UUID.fromString(token); - DiningReportDeliveryOutcome outcome = requiredEnum(body, "outcome", DiningReportDeliveryOutcome.class); - return switch (outcome) { - case SUCCEEDED -> { - requireFields(body, "attempt_token", "outcome", "message_ref"); - JsonNode message = body.get("message_ref"); - requireFields(message, "channel_id", "message_ts"); - yield new DiningReportDeliveryResultRequest(attemptToken, outcome, - new MessageReference(requiredText(message, "channel_id"), requiredText(message, "message_ts")), - null, null, null); - } - case NOT_APPLIED -> { - DiningReportDeliveryFailureReason reason = requiredEnum(body, "reason", - DiningReportDeliveryFailureReason.class); - Long retryAfterSeconds = null; - String errorCode = null; - switch (reason) { - case NOT_SENT -> requireFields(body, "attempt_token", "outcome", "reason"); - case RATE_LIMITED -> { - requireFields(body, "attempt_token", "outcome", "reason", "retry_after_seconds"); - JsonNode retry = body.get("retry_after_seconds"); - if (!retry.isIntegralNumber() || !retry.canConvertToLong()) { - throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); - } - retryAfterSeconds = retry.longValue(); - if (retryAfterSeconds < 1) { - throw CustomException.of(INVALID_REQUEST_BODY); - } - } - case REJECTED -> { - requireFields(body, "attempt_token", "outcome", "reason", "error_code"); - errorCode = requiredText(body, "error_code"); - if (errorCode.codePointCount(0, errorCode.length()) > 128) { - throw CustomException.of(INVALID_REQUEST_BODY); - } - } - } - yield new DiningReportDeliveryResultRequest(attemptToken, outcome, null, reason, - retryAfterSeconds, errorCode); - } - case UNCERTAIN -> { - requireFields(body, "attempt_token", "outcome"); - yield new DiningReportDeliveryResultRequest(attemptToken, outcome, null, null, null, null); - } - }; - } - - private static void requireObject(JsonNode value) { - if (value == null || value.isNull()) { + if (body == null || body.isNull()) { throw CustomException.of(INVALID_REQUEST_BODY); } - if (!value.isObject()) { + if (!body.isObject()) { throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); } - } - - private static void requireFields(JsonNode value, String... fieldNames) { - requireObject(value); - Set required = Set.of(fieldNames); - for (Iterator fields = value.fieldNames(); fields.hasNext();) { - if (!required.contains(fields.next())) { + body.fieldNames().forEachRemaining(field -> { + if (!FIELDS.contains(field)) { throw CustomException.of(INVALID_REQUEST_BODY); } + }); + String token = requiredText(body, "attempt_token"); + if (!token.matches(UUID_PATTERN)) { + throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); } - for (String field : required) { - if (!value.hasNonNull(field)) { - throw CustomException.of(INVALID_REQUEST_BODY); - } + String outcome = requiredText(body, "outcome"); + try { + return new DiningReportDeliveryResultRequest(UUID.fromString(token), + DiningReportDeliveryOutcome.valueOf(outcome)); + } catch (IllegalArgumentException e) { + throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); } } @@ -142,74 +71,4 @@ private static String requiredText(JsonNode body, String field) { } return value.textValue(); } - - private static > E requiredEnum(JsonNode body, String field, Class enumType) { - String value = requiredText(body, field); - try { - return Enum.valueOf(enumType, value); - } catch (IllegalArgumentException e) { - throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); - } - } - - @JsonNaming(SnakeCaseStrategy.class) - @Schema(name = "DiningReportDeliveryMessageReference", additionalProperties = Schema.AdditionalPropertiesValue.FALSE) - public record MessageReference( - @Schema(description = "확인한 메시지의 실제 채널", minLength = 1, requiredMode = REQUIRED) - @NotNull @Size(min = 1) String channelId, - @Schema(description = "슬랙 ts 원문 문자열. 숫자로 변환하지 않습니다.", minLength = 1, requiredMode = REQUIRED) - @NotNull @Size(min = 1) String messageTs - ) { - } - - @JsonNaming(SnakeCaseStrategy.class) - @Schema(name = "DiningReportDeliverySucceeded", additionalProperties = Schema.AdditionalPropertiesValue.FALSE, - requiredProperties = {"attempt_token", "outcome", "message_ref"}) - public record Succeeded( - @Schema(format = "uuid", pattern = UUID_PATTERN) UUID attemptToken, - @Schema(allowableValues = "SUCCEEDED") String outcome, - MessageReference messageRef - ) { - } - - @JsonNaming(SnakeCaseStrategy.class) - @Schema(name = "DiningReportDeliveryNotSent", additionalProperties = Schema.AdditionalPropertiesValue.FALSE, - requiredProperties = {"attempt_token", "outcome", "reason"}) - public record NotSent( - @Schema(format = "uuid", pattern = UUID_PATTERN) UUID attemptToken, - @Schema(allowableValues = "NOT_APPLIED") String outcome, - @Schema(allowableValues = "NOT_SENT") String reason - ) { - } - - @JsonNaming(SnakeCaseStrategy.class) - @Schema(name = "DiningReportDeliveryRateLimited", additionalProperties = Schema.AdditionalPropertiesValue.FALSE, - requiredProperties = {"attempt_token", "outcome", "reason", "retry_after_seconds"}) - public record RateLimited( - @Schema(format = "uuid", pattern = UUID_PATTERN) UUID attemptToken, - @Schema(allowableValues = "NOT_APPLIED") String outcome, - @Schema(allowableValues = "RATE_LIMITED") String reason, - @Schema(minimum = "1") Long retryAfterSeconds - ) { - } - - @JsonNaming(SnakeCaseStrategy.class) - @Schema(name = "DiningReportDeliveryRejected", additionalProperties = Schema.AdditionalPropertiesValue.FALSE, - requiredProperties = {"attempt_token", "outcome", "reason", "error_code"}) - public record Rejected( - @Schema(format = "uuid", pattern = UUID_PATTERN) UUID attemptToken, - @Schema(allowableValues = "NOT_APPLIED") String outcome, - @Schema(allowableValues = "REJECTED") String reason, - @Schema(minLength = 1, maxLength = 128) String errorCode - ) { - } - - @JsonNaming(SnakeCaseStrategy.class) - @Schema(name = "DiningReportDeliveryUncertain", additionalProperties = Schema.AdditionalPropertiesValue.FALSE, - requiredProperties = {"attempt_token", "outcome"}) - public record Uncertain( - @Schema(format = "uuid", pattern = UUID_PATTERN) UUID attemptToken, - @Schema(allowableValues = "UNCERTAIN") String outcome - ) { - } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultResponse.java index 3ede79226..ae0b6dfdf 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultResponse.java @@ -12,12 +12,12 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(SnakeCaseStrategy.class) -@Schema(description = "응답 시점의 현재 전송 상태. 제보의 status와 별개입니다.", +@Schema(description = "응답 시점의 현재 작업 상태. 제보의 status와 별개입니다.", additionalProperties = Schema.AdditionalPropertiesValue.FALSE) public record DiningReportDeliveryResultResponse( - @Schema(description = "전송 작업 UUID", format = "uuid", pattern = UUID_PATTERN, requiredMode = REQUIRED) + @Schema(description = "작업 UUID", format = "uuid", pattern = UUID_PATTERN, requiredMode = REQUIRED) UUID deliveryId, - @Schema(description = "같은 결과를 재통보해도 현재 전송 상태를 반환합니다.", requiredMode = REQUIRED) + @Schema(description = "QUEUED는 대기, IN_PROGRESS는 배정, DELIVERED는 삐봇이 성공을 통보한 상태입니다.", requiredMode = REQUIRED) DiningReportDeliveryStatus deliveryState ) { } diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportChange.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportChange.java index b03c7ac5e..16ffbb3a1 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportChange.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportChange.java @@ -4,19 +4,26 @@ import static lombok.AccessLevel.PROTECTED; import java.time.LocalDateTime; +import java.util.Objects; import java.util.UUID; +import org.hibernate.annotations.ColumnDefault; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @Entity -@Table(name = "dining_soldout_report_change") +@Table(name = "dining_soldout_report_change", uniqueConstraints = { + @UniqueConstraint(name = "uk_dining_report_change_delivery", columnNames = "delivery_id"), + @UniqueConstraint(name = "uk_dining_report_change_attempt", columnNames = "attempt_token") +}) @NoArgsConstructor(access = PROTECTED) public class DiningReportChange { @@ -46,8 +53,32 @@ public enum EventType { CREATED, PROCESSED } @Column(name = "occurred_at", nullable = false, columnDefinition = "DATETIME") private LocalDateTime occurredAt; + @Column(name = "delivery_id", columnDefinition = "BINARY(16)") + private UUID deliveryId; + + @Column(name = "report_snapshot", columnDefinition = "LONGTEXT") + private String reportSnapshot; + + @Enumerated(STRING) + @Column(name = "delivery_state", nullable = false, length = 16) + @ColumnDefault("'QUEUED'") + private DiningReportDeliveryStatus deliveryState; + + @Column(name = "attempt_token", columnDefinition = "BINARY(16)") + private UUID attemptToken; + + @Column(name = "expires_at", columnDefinition = "DATETIME(6)") + private LocalDateTime expiresAt; + + @Column(name = "next_attempt_at", columnDefinition = "DATETIME(6)") + private LocalDateTime nextAttemptAt; + + @Enumerated(STRING) + @Column(name = "accepted_outcome", length = 16) + private DiningReportDeliveryOutcome acceptedOutcome; + public static DiningReportChange from(long sequence, DiningReport report, EventType eventType, - LocalDateTime now) { + LocalDateTime now, String snapshot) { DiningReportChange change = new DiningReportChange(); change.sequence = sequence; change.reportId = report.getId(); @@ -56,6 +87,36 @@ public static DiningReportChange from(long sequence, DiningReport report, EventT change.processingType = report.getProcessingType(); change.processingId = report.getProcessingId(); change.occurredAt = now; + change.deliveryState = DiningReportDeliveryStatus.QUEUED; + change.initializeDelivery(snapshot); return change; } + + public void initializeDelivery(String snapshot) { + if (deliveryId == null) { + deliveryId = UUID.randomUUID(); + } + if (reportSnapshot == null) { + reportSnapshot = Objects.requireNonNull(snapshot, "제보 작업 내용이 없습니다."); + } + } + + public void issue(LocalDateTime now) { + attemptToken = UUID.randomUUID(); + expiresAt = now.plusSeconds(60); + nextAttemptAt = null; + acceptedOutcome = null; + deliveryState = DiningReportDeliveryStatus.IN_PROGRESS; + } + + public void accept(DiningReportDeliveryOutcome outcome, LocalDateTime now) { + acceptedOutcome = outcome; + if (outcome == DiningReportDeliveryOutcome.SUCCEEDED) { + deliveryState = DiningReportDeliveryStatus.DELIVERED; + nextAttemptAt = null; + } else { + deliveryState = DiningReportDeliveryStatus.QUEUED; + nextAttemptAt = now.plusSeconds(5); + } + } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDelivery.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDelivery.java deleted file mode 100644 index eb399aecb..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDelivery.java +++ /dev/null @@ -1,132 +0,0 @@ -package in.koreatech.koin.domain.dining.model; - -import static jakarta.persistence.EnumType.STRING; -import static lombok.AccessLevel.PROTECTED; - -import java.time.LocalDateTime; -import java.util.UUID; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "dining_soldout_report_delivery", uniqueConstraints = { - @UniqueConstraint(name = "uk_dining_delivery_snapshot", columnNames = {"report_id", "source_sequence"}) -}) -@NoArgsConstructor(access = PROTECTED) -public class DiningReportDelivery { - - @Id - @Column(columnDefinition = "BINARY(16)") - private UUID id; - - @Column(name = "report_id", nullable = false, updatable = false) - private Integer reportId; - - @Column(name = "source_sequence", nullable = false, updatable = false) - private Long sourceSequence; - - @Column(name = "report_snapshot", nullable = false, updatable = false, columnDefinition = "LONGTEXT") - private String reportSnapshot; - - @Enumerated(STRING) - @Column(nullable = false, updatable = false, length = 16) - private DiningReportDeliveryOperation operation; - - @Column(name = "workspace_id", nullable = false, updatable = false, columnDefinition = "LONGTEXT") - private String workspaceId; - - @Column(name = "channel_id", nullable = false, updatable = false, columnDefinition = "LONGTEXT") - private String channelId; - - @Column(name = "target_message_ts", updatable = false, columnDefinition = "LONGTEXT") - private String targetMessageTs; - - @Enumerated(STRING) - @Column(nullable = false, length = 16) - private DiningReportDeliveryStatus status; - - @Column(name = "active_attempt_token", columnDefinition = "BINARY(16)") - private UUID activeAttemptToken; - - @Column(name = "send_attempt_token", columnDefinition = "BINARY(16)") - private UUID sendAttemptToken; - - @Column(name = "next_attempt_at", columnDefinition = "DATETIME(6)") - private LocalDateTime nextAttemptAt; - - @Column(name = "confirmed_message_ts", columnDefinition = "LONGTEXT") - private String confirmedMessageTs; - - @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(6)") - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(6)") - private LocalDateTime updatedAt; - - public static DiningReportDelivery create(DiningReportDeliveryTarget target, LocalDateTime now) { - DiningReportDelivery delivery = new DiningReportDelivery(); - delivery.id = UUID.randomUUID(); - delivery.reportId = target.getReportId(); - delivery.sourceSequence = target.getDesiredSequence(); - delivery.reportSnapshot = target.getDesiredSnapshot(); - delivery.operation = target.getMessageTs() == null - ? DiningReportDeliveryOperation.CREATE : DiningReportDeliveryOperation.UPDATE; - delivery.workspaceId = target.getWorkspaceId(); - delivery.channelId = target.getChannelId(); - delivery.targetMessageTs = target.getMessageTs(); - delivery.status = DiningReportDeliveryStatus.QUEUED; - delivery.nextAttemptAt = now; - delivery.createdAt = now; - delivery.updatedAt = now; - return delivery; - } - - public void issue(DiningReportDeliveryAttempt attempt, LocalDateTime now) { - activeAttemptToken = attempt.getToken(); - if (attempt.getMode() == DiningReportDeliveryMode.SEND) { - sendAttemptToken = attempt.getToken(); - } - status = DiningReportDeliveryStatus.IN_PROGRESS; - nextAttemptAt = null; - updatedAt = now; - } - - public void awaitVerification(LocalDateTime now) { - activeAttemptToken = null; - status = DiningReportDeliveryStatus.UNCERTAIN; - nextAttemptAt = now; - updatedAt = now; - } - - public void queue(LocalDateTime nextAttemptAt, LocalDateTime now) { - activeAttemptToken = null; - status = DiningReportDeliveryStatus.QUEUED; - this.nextAttemptAt = nextAttemptAt; - updatedAt = now; - } - - public void needsAttention(LocalDateTime nextVerificationAt, LocalDateTime now) { - activeAttemptToken = null; - status = DiningReportDeliveryStatus.NEEDS_ATTENTION; - nextAttemptAt = nextVerificationAt; - updatedAt = now; - } - - public void succeed(String messageTs, LocalDateTime now) { - activeAttemptToken = null; - status = DiningReportDeliveryStatus.DELIVERED; - nextAttemptAt = null; - if (confirmedMessageTs == null) { - confirmedMessageTs = messageTs; - } - updatedAt = now; - } -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryAttempt.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryAttempt.java deleted file mode 100644 index 09ba74f2b..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryAttempt.java +++ /dev/null @@ -1,107 +0,0 @@ -package in.koreatech.koin.domain.dining.model; - -import static jakarta.persistence.EnumType.STRING; -import static lombok.AccessLevel.PROTECTED; - -import java.time.LocalDateTime; -import java.util.UUID; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "dining_soldout_report_delivery_attempt") -@NoArgsConstructor(access = PROTECTED) -public class DiningReportDeliveryAttempt { - - @Id - @Column(columnDefinition = "BINARY(16)") - private UUID token; - - @Column(name = "delivery_id", nullable = false, updatable = false, columnDefinition = "BINARY(16)") - private UUID deliveryId; - - @Enumerated(STRING) - @Column(nullable = false, updatable = false, length = 16) - private DiningReportDeliveryMode mode; - - @Column(name = "send_attempt_token", nullable = false, updatable = false, columnDefinition = "BINARY(16)") - private UUID sendAttemptToken; - - @Column(name = "issued_at", nullable = false, updatable = false, columnDefinition = "DATETIME(6)") - private LocalDateTime issuedAt; - - @Column(name = "expires_at", nullable = false, updatable = false, columnDefinition = "DATETIME(6)") - private LocalDateTime expiresAt; - - @Column(name = "expired_at", columnDefinition = "DATETIME(6)") - private LocalDateTime expiredAt; - - @Column(name = "invalidated_at", columnDefinition = "DATETIME(6)") - private LocalDateTime invalidatedAt; - - @Enumerated(STRING) - @Column(name = "accepted_outcome", length = 16) - private DiningReportDeliveryOutcome acceptedOutcome; - - @Column(name = "accepted_result", columnDefinition = "LONGTEXT") - private String acceptedResult; - - @Column(name = "original_result", columnDefinition = "LONGTEXT") - private String originalResult; - - @Column(name = "evidence_history", nullable = false, columnDefinition = "LONGTEXT") - private String evidenceHistory; - - @Column(name = "result_at", columnDefinition = "DATETIME(6)") - private LocalDateTime resultAt; - - public static DiningReportDeliveryAttempt issue(DiningReportDelivery delivery, DiningReportDeliveryMode mode, - LocalDateTime now) { - DiningReportDeliveryAttempt attempt = new DiningReportDeliveryAttempt(); - attempt.token = UUID.randomUUID(); - attempt.deliveryId = delivery.getId(); - attempt.mode = mode; - attempt.sendAttemptToken = mode == DiningReportDeliveryMode.SEND - ? attempt.token : delivery.getSendAttemptToken(); - attempt.issuedAt = now; - attempt.expiresAt = now.plusSeconds(60); - attempt.evidenceHistory = "[]"; - return attempt; - } - - public void recordEvidence(String result, String history) { - if (originalResult == null) { - originalResult = result; - } - evidenceHistory = history; - } - - public void accept(DiningReportDeliveryOutcome outcome, String result, LocalDateTime now) { - acceptedOutcome = outcome; - acceptedResult = result; - resultAt = now; - } - - public boolean hasDefiniteOutcome() { - return acceptedOutcome != null && acceptedOutcome != DiningReportDeliveryOutcome.UNCERTAIN; - } - - public void expire(LocalDateTime now) { - if (expiredAt == null) { - expiredAt = now; - } - } - - public void invalidate(LocalDateTime now) { - if (invalidatedAt == null) { - invalidatedAt = now; - } - } -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryFailureReason.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryFailureReason.java deleted file mode 100644 index f8bb4bd6f..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryFailureReason.java +++ /dev/null @@ -1,7 +0,0 @@ -package in.koreatech.koin.domain.dining.model; - -public enum DiningReportDeliveryFailureReason { - NOT_SENT, - RATE_LIMITED, - REJECTED -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryMode.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryMode.java deleted file mode 100644 index 0b6ca413a..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package in.koreatech.koin.domain.dining.model; - -public enum DiningReportDeliveryMode { - SEND, - VERIFY -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOperation.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOperation.java deleted file mode 100644 index 1e128ba98..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOperation.java +++ /dev/null @@ -1,6 +0,0 @@ -package in.koreatech.koin.domain.dining.model; - -public enum DiningReportDeliveryOperation { - CREATE, - UPDATE -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOutcome.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOutcome.java index 4142ee93c..43f848309 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOutcome.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOutcome.java @@ -2,6 +2,5 @@ public enum DiningReportDeliveryOutcome { SUCCEEDED, - NOT_APPLIED, - UNCERTAIN + FAILED } diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryStatus.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryStatus.java index 661de9b61..604f2dbbe 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryStatus.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryStatus.java @@ -3,7 +3,5 @@ public enum DiningReportDeliveryStatus { QUEUED, IN_PROGRESS, - UNCERTAIN, - DELIVERED, - NEEDS_ATTENTION + DELIVERED } diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryTarget.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryTarget.java deleted file mode 100644 index c596bc2d1..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryTarget.java +++ /dev/null @@ -1,115 +0,0 @@ -package in.koreatech.koin.domain.dining.model; - -import static jakarta.persistence.EnumType.STRING; -import static lombok.AccessLevel.PROTECTED; - -import java.time.LocalDateTime; -import java.util.UUID; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "dining_soldout_report_delivery_target") -@NoArgsConstructor(access = PROTECTED) -public class DiningReportDeliveryTarget { - - public enum HoldReason { LEGACY, REJECTED, CONFLICT } - - @Id - @Column(name = "report_id") - private Integer reportId; - - @Column(name = "desired_sequence", nullable = false) - private Long desiredSequence; - - @Column(name = "desired_snapshot", nullable = false, columnDefinition = "LONGTEXT") - private String desiredSnapshot; - - @Column(name = "confirmed_sequence", nullable = false) - private Long confirmedSequence; - - @Column(name = "workspace_id", columnDefinition = "LONGTEXT") - private String workspaceId; - - @Column(name = "channel_id", columnDefinition = "LONGTEXT") - private String channelId; - - @Column(name = "message_ts", columnDefinition = "LONGTEXT") - private String messageTs; - - @Column(name = "active_delivery_id", columnDefinition = "BINARY(16)") - private UUID activeDeliveryId; - - @Enumerated(STRING) - @Column(name = "hold_reason", length = 16) - private HoldReason holdReason; - - @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(6)") - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(6)") - private LocalDateTime updatedAt; - - public static DiningReportDeliveryTarget create(Integer reportId, long sequence, String snapshot, - boolean legacy, LocalDateTime now) { - DiningReportDeliveryTarget target = new DiningReportDeliveryTarget(); - target.reportId = reportId; - target.desiredSequence = sequence; - target.desiredSnapshot = snapshot; - target.confirmedSequence = 0L; - target.holdReason = legacy ? HoldReason.LEGACY : null; - target.createdAt = now; - target.updatedAt = now; - return target; - } - - public void recordDesired(long sequence, String snapshot, LocalDateTime now) { - if (sequence <= desiredSequence) { - throw new IllegalStateException("제보 전송 순번은 이전 상태보다 커야 합니다."); - } - desiredSequence = sequence; - desiredSnapshot = snapshot; - updatedAt = now; - } - - public void captureRouting(String workspaceId, String channelId, LocalDateTime now) { - if (this.workspaceId == null && this.channelId == null && holdReason == null) { - this.workspaceId = workspaceId; - this.channelId = channelId; - updatedAt = now; - } - } - - public boolean hasRouting() { - return workspaceId != null && channelId != null; - } - - public void activate(UUID deliveryId, LocalDateTime now) { - activeDeliveryId = deliveryId; - updatedAt = now; - } - - public void confirm(DiningReportDelivery delivery, String messageTs, LocalDateTime now) { - // Only the issued snapshot is acknowledged, never the current desired snapshot. - confirmedSequence = Math.max(confirmedSequence, delivery.getSourceSequence()); - if (this.messageTs == null) { - this.messageTs = messageTs; - } - if (delivery.getId().equals(activeDeliveryId)) { - activeDeliveryId = null; - } - updatedAt = now; - } - - public void hold(HoldReason reason, LocalDateTime now) { - holdReason = reason; - updatedAt = now; - } -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportSequence.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportSequence.java index 6b8cec767..fdb6797ce 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportSequence.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportSequence.java @@ -2,8 +2,6 @@ import static lombok.AccessLevel.PROTECTED; -import java.time.LocalDateTime; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -24,21 +22,8 @@ public class DiningReportSequence { @Column(name = "last_sequence", nullable = false) private Long lastSequence; - @Column(name = "delivery_cooldown_until", columnDefinition = "DATETIME(6)") - private LocalDateTime deliveryCooldownUntil; - public long next() { lastSequence = Math.addExact(lastSequence, 1L); return lastSequence; } - - public boolean isDeliveryCoolingDown(LocalDateTime now) { - return deliveryCooldownUntil != null && now.isBefore(deliveryCooldownUntil); - } - - public void extendDeliveryCooldown(LocalDateTime until) { - if (deliveryCooldownUntil == null || until.isAfter(deliveryCooldownUntil)) { - deliveryCooldownUntil = until; - } - } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportChangeRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportChangeRepository.java index 9d089f263..97a215697 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportChangeRepository.java +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportChangeRepository.java @@ -1,9 +1,14 @@ package in.koreatech.koin.domain.dining.repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.dining.model.DiningReportChange; @@ -11,6 +16,23 @@ public interface DiningReportChangeRepository extends Repository findBySequenceGreaterThanAndSequenceLessThanEqualOrderBySequenceAsc( - Long cursor, Long lastSequence, Pageable pageable); + Optional findByDeliveryId(UUID deliveryId); + + Optional findByAttemptToken(UUID attemptToken); + + @Query(""" + SELECT c FROM DiningReportChange c + WHERE ( + (c.deliveryState = in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus.QUEUED + AND (c.nextAttemptAt IS NULL OR c.nextAttemptAt <= :now)) + OR (c.deliveryState = in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus.IN_PROGRESS + AND c.expiresAt <= :now) + ) AND NOT EXISTS ( + SELECT earlier.sequence FROM DiningReportChange earlier + WHERE earlier.reportId = c.reportId AND earlier.sequence < c.sequence + AND earlier.deliveryState <> in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus.DELIVERED + ) + ORDER BY c.sequence + """) + List findClaimable(@Param("now") LocalDateTime now, Pageable pageable); } diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryAttemptRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryAttemptRepository.java deleted file mode 100644 index facafc1e5..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryAttemptRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package in.koreatech.koin.domain.dining.repository; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryAttempt; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryMode; - -public interface DiningReportDeliveryAttemptRepository extends Repository { - - DiningReportDeliveryAttempt save(DiningReportDeliveryAttempt attempt); - - Optional findById(UUID token); - - List findByDeliveryIdAndSendAttemptTokenAndMode(UUID deliveryId, - UUID sendAttemptToken, DiningReportDeliveryMode mode); -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryRepository.java deleted file mode 100644 index c19978511..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package in.koreatech.koin.domain.dining.repository; - -import java.util.Optional; -import java.util.UUID; - -import org.springframework.data.repository.Repository; - -import in.koreatech.koin.domain.dining.model.DiningReportDelivery; - -public interface DiningReportDeliveryRepository extends Repository { - - DiningReportDelivery save(DiningReportDelivery delivery); - - Optional findById(UUID id); -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryTargetRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryTargetRepository.java deleted file mode 100644 index d26d125f9..000000000 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryTargetRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package in.koreatech.koin.domain.dining.repository; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; -import org.springframework.data.repository.query.Param; - -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryTarget; - -public interface DiningReportDeliveryTargetRepository extends Repository { - - DiningReportDeliveryTarget save(DiningReportDeliveryTarget target); - - Optional findById(Integer reportId); - - @Query(""" - SELECT t FROM DiningReportDeliveryTarget t - WHERE t.reportId > :afterReportId AND t.holdReason IS NULL - AND (t.activeDeliveryId IS NOT NULL OR t.desiredSequence > t.confirmedSequence) - ORDER BY t.reportId - """) - List findCandidates(@Param("afterReportId") Integer afterReportId, - Pageable pageable); -} diff --git a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportChangeService.java b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportChangeService.java index d551b73bf..ba12f7764 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportChangeService.java +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportChangeService.java @@ -8,6 +8,10 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.domain.dining.dto.DiningReportResponse; import in.koreatech.koin.domain.dining.model.DiningReport; import in.koreatech.koin.domain.dining.model.DiningReportChange; import in.koreatech.koin.domain.dining.model.DiningReportSequence; @@ -21,7 +25,7 @@ public class DiningReportChangeService { private final DiningReportSequenceRepository sequenceRepository; private final DiningReportChangeRepository changeRepository; - private final DiningReportDeliveryService deliveryService; + private final ObjectMapper objectMapper; @Transactional(propagation = Propagation.MANDATORY) public void append(List reports, DiningReportChange.EventType eventType, LocalDateTime now) { @@ -33,8 +37,15 @@ public void append(List reports, DiningReportChange.EventType even "식단 제보 변경 순번 초기값이 없습니다."); for (DiningReport report : reports) { long sourceSequence = sequence.next(); - changeRepository.save(DiningReportChange.from(sourceSequence, report, eventType, now)); - deliveryService.recordChange(report, sourceSequence, eventType, now); + changeRepository.save(DiningReportChange.from(sourceSequence, report, eventType, now, snapshot(report))); + } + } + + private String snapshot(DiningReport report) { + try { + return objectMapper.writeValueAsString(DiningReportResponse.from(report)); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("제보 작업 내용을 저장할 수 없습니다.", exception); } } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportDeliveryService.java b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportDeliveryService.java index 887d8abff..6730ca997 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportDeliveryService.java +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportDeliveryService.java @@ -1,20 +1,13 @@ package in.koreatech.koin.domain.dining.service; -import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryFailureReason.RATE_LIMITED; -import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryMode.SEND; -import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryMode.VERIFY; -import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome.NOT_APPLIED; -import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome.SUCCEEDED; -import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome.UNCERTAIN; -import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_DELIVERY_CONFLICT; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_DINING_REPORT; import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_DINING_REPORT_DELIVERY; import java.time.Clock; -import java.time.DateTimeException; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -22,32 +15,20 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import in.koreatech.koin.domain.dining.config.DiningReportDeliveryProperties; import in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResponse; import in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResultRequest; import in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResultResponse; import in.koreatech.koin.domain.dining.dto.DiningReportResponse; import in.koreatech.koin.domain.dining.model.DiningReport; import in.koreatech.koin.domain.dining.model.DiningReportChange; -import in.koreatech.koin.domain.dining.model.DiningReportDelivery; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryAttempt; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryMode; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryOperation; import in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryTarget; -import in.koreatech.koin.domain.dining.model.DiningReportSequence; -import in.koreatech.koin.domain.dining.repository.DiningReportDeliveryAttemptRepository; -import in.koreatech.koin.domain.dining.repository.DiningReportDeliveryRepository; -import in.koreatech.koin.domain.dining.repository.DiningReportDeliveryTargetRepository; +import in.koreatech.koin.domain.dining.repository.DiningReportChangeRepository; +import in.koreatech.koin.domain.dining.repository.DiningReportRepository; import in.koreatech.koin.domain.dining.repository.DiningReportSequenceRepository; import in.koreatech.koin.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -57,311 +38,79 @@ public class DiningReportDeliveryService { private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final int CANDIDATE_BATCH_SIZE = 100; - private static final long VERIFY_RETRY_SECONDS = 300; - private static final LocalDateTime LAST_DATABASE_TIME = LocalDateTime.of(9999, 12, 31, 23, 59, 59, 999_999_000); private final DiningReportSequenceRepository sequenceRepository; - private final DiningReportDeliveryTargetRepository targetRepository; - private final DiningReportDeliveryRepository deliveryRepository; - private final DiningReportDeliveryAttemptRepository attemptRepository; - private final DiningReportDeliveryProperties properties; + private final DiningReportChangeRepository changeRepository; + private final DiningReportRepository reportRepository; private final ObjectMapper objectMapper; private final Clock clock; - public record Result(DiningReportDeliveryResultResponse response, boolean conflict) { } - - @Transactional(propagation = Propagation.MANDATORY) - public void recordChange(DiningReport report, long sequence, DiningReportChange.EventType eventType, - LocalDateTime now) { - // The caller already holds the sequence lock as its final business lock. - String snapshot = serialize(DiningReportResponse.from(report)); - DiningReportDeliveryTarget target = targetRepository.findById(report.getId()).orElse(null); - if (target == null) { - target = targetRepository.save(DiningReportDeliveryTarget.create(report.getId(), sequence, snapshot, - eventType != DiningReportChange.EventType.CREATED, now)); - } else { - target.recordDesired(sequence, snapshot, now); - } - captureRouting(target, now); - } - @Transactional(isolation = Isolation.READ_COMMITTED) public Optional claim() { - DiningReportSequence gate = lockSequence(); + lockSequence(); LocalDateTime now = now(); - int afterReportId = 0; - while (true) { - List candidates = targetRepository.findCandidates(afterReportId, - PageRequest.of(0, CANDIDATE_BATCH_SIZE)); - if (candidates.isEmpty()) { - return Optional.empty(); + return changeRepository.findClaimable(now, PageRequest.of(0, 1)).stream().findFirst().map(change -> { + // Old-version inserts omit task metadata. Read committed state without reversing business lock order. + String snapshot = change.getReportSnapshot(); + if (snapshot == null) { + DiningReport report = reportRepository.findById(change.getReportId()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING_REPORT)); + snapshot = serialize(DiningReportResponse.from(report)); } - for (DiningReportDeliveryTarget target : candidates) { - afterReportId = target.getReportId(); - captureRouting(target, now); - if (!target.hasRouting()) { - continue; - } - DiningReportDelivery delivery = activeDelivery(target); - if (delivery != null) { - expireActiveAttempt(target, delivery, now); - if (delivery.getActiveAttemptToken() != null || delivery.getNextAttemptAt() == null - || now.isBefore(delivery.getNextAttemptAt())) { - continue; - } - } - DiningReportDeliveryMode mode = delivery == null - || delivery.getStatus() == DiningReportDeliveryStatus.QUEUED ? SEND : VERIFY; - // The integration-wide write cooldown never prevents read-only verification. - if (mode == SEND && gate.isDeliveryCoolingDown(now)) { - continue; - } - if (delivery == null) { - delivery = deliveryRepository.save(DiningReportDelivery.create(target, now)); - target.activate(delivery.getId(), now); - } - DiningReportDeliveryAttempt attempt = attemptRepository.save( - DiningReportDeliveryAttempt.issue(delivery, mode, now)); - delivery.issue(attempt, now); - return Optional.of(response(delivery, attempt)); - } - } + change.initializeDelivery(snapshot); + change.issue(now); + return new DiningReportDeliveryResponse(change.getDeliveryId(), change.getAttemptToken(), + change.getExpiresAt().atZone(KST).toOffsetDateTime(), snapshot(change.getReportSnapshot())); + }); } - // The HTTP lane throws 409 only after this independent transaction has committed the hold and evidence. - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) - public Result recordResult(UUID deliveryId, DiningReportDeliveryResultRequest request) { - DiningReportSequence gate = lockSequence(); - DiningReportDelivery delivery = deliveryRepository.findById(deliveryId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING_REPORT_DELIVERY)); - DiningReportDeliveryAttempt attempt = attemptRepository.findById(request.attemptToken()) - .filter(found -> deliveryId.equals(found.getDeliveryId())) + @Transactional(isolation = Isolation.READ_COMMITTED) + public DiningReportDeliveryResultResponse recordResult(UUID deliveryId, DiningReportDeliveryResultRequest request) { + lockSequence(); + DiningReportChange change = changeRepository.findByDeliveryId(deliveryId) .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING_REPORT_DELIVERY)); - if (attempt.getMode() == VERIFY && request.outcome() == NOT_APPLIED) { - throw CustomException.of(INVALID_REQUEST_BODY); - } - DiningReportDeliveryTarget target = targetRepository.findById(delivery.getReportId()).orElseThrow(); - LocalDateTime now = now(); - expireActiveAttempt(target, delivery, now); - String result = serialize(request); - recordEvidence(attempt, result, now); - - // Replayed uncertainty and stale VERIFY failures cannot undo later definite evidence. - if (attempt.hasDefiniteOutcome()) { - if (request.outcome() == UNCERTAIN || sameResult(attempt.getAcceptedResult(), result)) { - return result(target, delivery, false); - } - return conflict(target, delivery, now); - } - if (request.outcome() == UNCERTAIN) { - if (attempt.getAcceptedOutcome() != UNCERTAIN) { - attempt.accept(UNCERTAIN, result, now); - if (isCurrentAttempt(target, delivery, attempt)) { - if (attempt.getMode() == SEND) { - delivery.awaitVerification(now); - } else { - delivery.needsAttention(now.plusSeconds(VERIFY_RETRY_SECONDS), now); - } - } - } - return result(target, delivery, false); - } - if (request.outcome() == SUCCEEDED) { - return recordSuccess(target, delivery, attempt, request, result, now); - } - // NOT_APPLIED is definitive only for this SEND, never evidence obtained by searching Slack. - if (delivery.getConfirmedMessageTs() != null) { - return conflict(target, delivery, now); - } - attempt.accept(NOT_APPLIED, result, now); - invalidateVerifications(delivery, attempt.getToken(), now); - LocalDateTime nextAttemptAt = now; - if (request.reason() == RATE_LIMITED) { - nextAttemptAt = retryAt(now, request.retryAfterSeconds()); - // Accepted rate-limit evidence applies to the integration even when this report remains held. - gate.extendDeliveryCooldown(nextAttemptAt); - } - if (!attempt.getToken().equals(delivery.getSendAttemptToken()) || target.getHoldReason() != null) { - return result(target, delivery, false); - } - switch (request.reason()) { - case NOT_SENT, RATE_LIMITED -> delivery.queue(nextAttemptAt, now); - case REJECTED -> { - target.hold(DiningReportDeliveryTarget.HoldReason.REJECTED, now); - delivery.needsAttention(null, now); + if (!Objects.equals(request.attemptToken(), change.getAttemptToken())) { + if (changeRepository.findByAttemptToken(request.attemptToken()) + .filter(other -> !deliveryId.equals(other.getDeliveryId())).isPresent()) { + throw CustomException.of(NOT_FOUND_DINING_REPORT_DELIVERY); } + throw CustomException.of(DINING_REPORT_DELIVERY_CONFLICT); } - return result(target, delivery, false); - } - - private Result recordSuccess(DiningReportDeliveryTarget target, DiningReportDelivery delivery, - DiningReportDeliveryAttempt attempt, DiningReportDeliveryResultRequest request, String result, - LocalDateTime now) { - if (!matchesTarget(target, delivery, request.messageRef())) { - return conflict(target, delivery, now); - } - if (attempt.getMode() == VERIFY) { - DiningReportDeliveryAttempt send = attemptRepository.findById(attempt.getSendAttemptToken()) - .orElseThrow(); - if (send.getAcceptedOutcome() == NOT_APPLIED) { - return conflict(target, delivery, now); + if (change.getAcceptedOutcome() != null) { + if (change.getAcceptedOutcome() != request.outcome()) { + throw CustomException.of(DINING_REPORT_DELIVERY_CONFLICT); } + return result(change); } - attempt.accept(SUCCEEDED, result, now); - invalidateVerifications(delivery, attempt.getSendAttemptToken(), now); - if (target.getHoldReason() == null) { - delivery.succeed(request.messageRef().messageTs(), now); - target.confirm(delivery, request.messageRef().messageTs(), now); - } - return result(target, delivery, false); - } - - private boolean matchesTarget(DiningReportDeliveryTarget target, DiningReportDelivery delivery, - DiningReportDeliveryResultRequest.MessageReference reference) { - if (!Objects.equals(delivery.getWorkspaceId(), target.getWorkspaceId()) - || !Objects.equals(delivery.getChannelId(), target.getChannelId()) - || !Objects.equals(reference.channelId(), delivery.getChannelId())) { - return false; - } - if (delivery.getOperation() == DiningReportDeliveryOperation.UPDATE - && (!Objects.equals(reference.messageTs(), delivery.getTargetMessageTs()) - || !Objects.equals(reference.messageTs(), target.getMessageTs()))) { - return false; + if (change.getDeliveryState() != DiningReportDeliveryStatus.IN_PROGRESS) { + throw CustomException.of(DINING_REPORT_DELIVERY_CONFLICT); } - return (target.getMessageTs() == null || Objects.equals(reference.messageTs(), target.getMessageTs())) - && (delivery.getConfirmedMessageTs() == null - || Objects.equals(reference.messageTs(), delivery.getConfirmedMessageTs())); + // Expiry permits reassignment; the current token can still acknowledge until it is replaced. + change.accept(request.outcome(), now()); + return result(change); } - private void expireActiveAttempt(DiningReportDeliveryTarget target, DiningReportDelivery delivery, - LocalDateTime now) { - if (target.getHoldReason() != null || delivery.getActiveAttemptToken() == null) { - return; - } - DiningReportDeliveryAttempt attempt = attemptRepository.findById(delivery.getActiveAttemptToken()) - .orElseThrow(); - if (now.isBefore(attempt.getExpiresAt())) { - return; - } - attempt.expire(now); - if (attempt.getMode() == SEND) { - delivery.awaitVerification(now); - } else { - delivery.needsAttention(attempt.getExpiresAt().plusSeconds(VERIFY_RETRY_SECONDS), now); - } + private DiningReportDeliveryResultResponse result(DiningReportChange change) { + return new DiningReportDeliveryResultResponse(change.getDeliveryId(), change.getDeliveryState()); } - private boolean isCurrentAttempt(DiningReportDeliveryTarget target, DiningReportDelivery delivery, - DiningReportDeliveryAttempt attempt) { - return target.getHoldReason() == null && attempt.getInvalidatedAt() == null - && attempt.getToken().equals(delivery.getActiveAttemptToken()); - } - - private Result conflict(DiningReportDeliveryTarget target, DiningReportDelivery delivery, LocalDateTime now) { - target.hold(DiningReportDeliveryTarget.HoldReason.CONFLICT, now); - invalidateVerifications(delivery, delivery.getSendAttemptToken(), now); - // A late contradiction can arrive while a newer immutable delivery is active for this report. - DiningReportDelivery active = activeDelivery(target); - if (active != null && !active.getId().equals(delivery.getId())) { - invalidateVerifications(active, active.getSendAttemptToken(), now); - if (active.getActiveAttemptToken() != null) { - attemptRepository.findById(active.getActiveAttemptToken()).orElseThrow().invalidate(now); - } - active.needsAttention(null, now); - } - if (delivery.getActiveAttemptToken() != null) { - attemptRepository.findById(delivery.getActiveAttemptToken()).orElseThrow().invalidate(now); - } - delivery.needsAttention(null, now); - return result(target, delivery, true); - } - - private void invalidateVerifications(DiningReportDelivery delivery, UUID sendToken, LocalDateTime now) { - if (sendToken != null) { - attemptRepository.findByDeliveryIdAndSendAttemptTokenAndMode(delivery.getId(), sendToken, VERIFY) - .forEach(attempt -> attempt.invalidate(now)); - } - } - - private Result result(DiningReportDeliveryTarget target, DiningReportDelivery delivery, boolean conflict) { - DiningReportDeliveryStatus state = target.getHoldReason() == null - ? delivery.getStatus() : DiningReportDeliveryStatus.NEEDS_ATTENTION; - return new Result(new DiningReportDeliveryResultResponse(delivery.getId(), state), conflict); - } - - private DiningReportDelivery activeDelivery(DiningReportDeliveryTarget target) { - return target.getActiveDeliveryId() == null ? null - : deliveryRepository.findById(target.getActiveDeliveryId()).orElseThrow(); - } - - private void captureRouting(DiningReportDeliveryTarget target, LocalDateTime now) { - if (properties.isConfigured()) { - target.captureRouting(properties.getWorkspaceId(), properties.getChannelId(), now); - } - } - - private DiningReportSequence lockSequence() { - return Objects.requireNonNull(sequenceRepository.findForUpdate(), "식단 제보 변경 순번 초기값이 없습니다."); - } - - private DiningReportDeliveryResponse response(DiningReportDelivery delivery, DiningReportDeliveryAttempt attempt) { - return new DiningReportDeliveryResponse(delivery.getId(), attempt.getToken(), attempt.getMode(), - delivery.getOperation(), attempt.getExpiresAt().atZone(KST).toOffsetDateTime(), - new DiningReportDeliveryResponse.Target(delivery.getWorkspaceId(), delivery.getChannelId(), - delivery.getTargetMessageTs()), snapshot(delivery.getReportSnapshot())); + private void lockSequence() { + Objects.requireNonNull(sequenceRepository.findForUpdate(), "식단 제보 변경 순번 초기값이 없습니다."); } private DiningReportResponse snapshot(String value) { try { return objectMapper.readValue(value, DiningReportResponse.class); } catch (JsonProcessingException exception) { - throw new IllegalStateException("저장된 제보 전송 내용을 읽을 수 없습니다.", exception); + throw new IllegalStateException("저장된 제보 작업 내용을 읽을 수 없습니다.", exception); } } - private String serialize(Object value) { + private String serialize(DiningReportResponse response) { try { - return objectMapper.writeValueAsString(value); + return objectMapper.writeValueAsString(response); } catch (JsonProcessingException exception) { - throw new IllegalStateException("제보 전송 내용을 저장할 수 없습니다.", exception); - } - } - - private boolean sameResult(String previous, String result) { - return readTree(previous).equals(readTree(result)); - } - - private void recordEvidence(DiningReportDeliveryAttempt attempt, String result, LocalDateTime now) { - ArrayNode history = (ArrayNode)readTree(attempt.getEvidenceHistory()); - JsonNode evidence = readTree(result); - for (JsonNode receipt : history) { - if (receipt.get("result").equals(evidence)) { - return; - } - } - ObjectNode receipt = objectMapper.createObjectNode(); - receipt.put("received_at", now.toString()); - receipt.set("result", evidence); - history.add(receipt); - attempt.recordEvidence(result, serialize(history)); - } - - private JsonNode readTree(String value) { - try { - return objectMapper.readTree(value); - } catch (JsonProcessingException exception) { - throw new IllegalStateException("저장된 제보 전송 결과를 읽을 수 없습니다.", exception); - } - } - - private LocalDateTime retryAt(LocalDateTime now, long seconds) { - // The contract sets no upper bound on Retry-After; very large delays stay held at MySQL's last date. - try { - LocalDateTime until = now.plusSeconds(seconds); - return until.isAfter(LAST_DATABASE_TIME) ? LAST_DATABASE_TIME : until; - } catch (DateTimeException | ArithmeticException exception) { - return LAST_DATABASE_TIME; + throw new IllegalStateException("제보 작업 내용을 저장할 수 없습니다.", exception); } } diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index 32b269ed9..2f7a11b22 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -14,12 +14,12 @@ public enum ApiResponseCode { BOT_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "삐봇 서비스 인증에 실패했습니다."), NOT_FOUND_DINING(HttpStatus.NOT_FOUND, "식단을 찾을 수 없습니다."), NOT_FOUND_DINING_REPORT(HttpStatus.NOT_FOUND, "품절 제보를 찾을 수 없습니다."), - NOT_FOUND_DINING_REPORT_DELIVERY(HttpStatus.NOT_FOUND, "전송 작업을 찾을 수 없습니다."), + NOT_FOUND_DINING_REPORT_DELIVERY(HttpStatus.NOT_FOUND, "배정된 작업을 찾을 수 없습니다."), DINING_ALREADY_SOLD_OUT(HttpStatus.CONFLICT, "이미 품절된 식단입니다."), DINING_REPORT_ALREADY_SUBMITTED(HttpStatus.CONFLICT, "이미 제보한 식단입니다."), IDEMPOTENCY_KEY_CONFLICT(HttpStatus.CONFLICT, "동일한 요청 키에 다른 요청 내용이 사용되었습니다."), DINING_REPORT_ALREADY_PROCESSED(HttpStatus.CONFLICT, "이미 다른 결과로 처리된 제보입니다."), - DINING_REPORT_DELIVERY_CONFLICT(HttpStatus.CONFLICT, "전송 작업의 확인된 결과와 일치하지 않습니다."), + DINING_REPORT_DELIVERY_CONFLICT(HttpStatus.CONFLICT, "현재 작업 시도 또는 이미 접수한 결과와 일치하지 않습니다."), IMAGE_STORAGE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이미지 저장소에 연결할 수 없습니다."), /** diff --git a/src/main/resources/db/migration/V14__decouple_dining_report_delivery.sql b/src/main/resources/db/migration/V14__decouple_dining_report_delivery.sql new file mode 100644 index 000000000..3660517f3 --- /dev/null +++ b/src/main/resources/db/migration/V14__decouple_dining_report_delivery.sql @@ -0,0 +1,45 @@ +ALTER TABLE dining_soldout_report_change + ADD COLUMN delivery_id BINARY(16) NULL, + ADD COLUMN report_snapshot LONGTEXT NULL, + ADD COLUMN delivery_state VARCHAR(16) NOT NULL DEFAULT 'QUEUED', + ADD COLUMN attempt_token BINARY(16) NULL, + ADD COLUMN expires_at DATETIME(6) NULL, + ADD COLUMN next_attempt_at DATETIME(6) NULL, + ADD COLUMN accepted_outcome VARCHAR(16) NULL, + ADD UNIQUE KEY uk_dining_report_change_delivery (delivery_id), + ADD UNIQUE KEY uk_dining_report_change_attempt (attempt_token), + ADD KEY idx_dining_report_change_claim (delivery_state, next_attempt_at, sequence), + ADD CONSTRAINT chk_dining_report_change_snapshot CHECK ( + report_snapshot IS NULL OR JSON_VALID(report_snapshot) + ), + ADD CONSTRAINT chk_dining_report_change_delivery_state CHECK ( + delivery_state IN ('QUEUED', 'IN_PROGRESS', 'DELIVERED') + ), + ADD CONSTRAINT chk_dining_report_change_outcome CHECK ( + accepted_outcome IS NULL OR accepted_outcome IN ('SUCCEEDED', 'FAILED') + ), + ADD CONSTRAINT chk_dining_report_change_lease CHECK ( + delivery_state <> 'IN_PROGRESS' + OR (delivery_id IS NOT NULL AND report_snapshot IS NOT NULL + AND attempt_token IS NOT NULL AND expires_at IS NOT NULL + AND next_attempt_at IS NULL AND accepted_outcome IS NULL) + ); + +-- Freeze the one-time legacy boundary with the same gate used by both application versions. +-- Writers committing after this transaction keep the QUEUED default, including old-version inserts. +START TRANSACTION; +SELECT last_sequence INTO @dining_report_delivery_migration_sequence +FROM dining_soldout_report_sequence WHERE id = 1 FOR UPDATE; + +UPDATE dining_soldout_report_change c +LEFT JOIN dining_soldout_report_delivery_target t ON t.report_id = c.report_id +LEFT JOIN dining_soldout_report_delivery d ON d.report_id = c.report_id AND d.source_sequence = c.sequence +SET c.delivery_id = d.id, + c.report_snapshot = COALESCE(d.report_snapshot, + CASE WHEN t.desired_sequence = c.sequence THEN t.desired_snapshot ELSE NULL END), + c.delivery_state = CASE + WHEN t.desired_sequence = c.sequence AND t.desired_sequence > t.confirmed_sequence THEN 'QUEUED' + ELSE 'DELIVERED' + END +WHERE c.sequence <= @dining_report_delivery_migration_sequence; +COMMIT; diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java index 5091baece..4249b1182 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.mockingDetails; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; +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.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -16,9 +17,9 @@ import java.time.Clock; import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -60,11 +61,7 @@ import in.koreatech.koin.infrastructure.s3.client.S3Client; @Transactional(propagation = Propagation.NOT_SUPPORTED) -@TestPropertySource(properties = { - "dining.report.bot-token=test-dining-report-bot-token", - "dining.report.delivery.workspace-id=T_EXAMPLE", - "dining.report.delivery.channel-id=C_EXAMPLE" -}) +@TestPropertySource(properties = "dining.report.bot-token=test-dining-report-bot-token") class DiningReportDeliveryApiTest extends AcceptanceTest { private static final String BOT_PATH = "/internal/dining/soldout-reports"; @@ -73,7 +70,6 @@ class DiningReportDeliveryApiTest extends AcceptanceTest { private static final String IMAGE_DOMAIN = "https://test.koreatech.in/"; private static final String IMAGE_URL = IMAGE_DOMAIN + "upload/COOP/2024/1/15/e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg"; - private static final String MESSAGE_TS = "1791257400.000100"; private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final Instant START = Instant.parse("2024-01-15T03:00:00Z"); private static final long TIMEOUT_SECONDS = 10; @@ -122,7 +118,6 @@ void setUp() { .thenAnswer(invocation -> Clock.fixed(now.get(), invocation.getArgument(0))); clear(); new TransactionTemplate(transactionManager).executeWithoutResult(status -> { - // clear()가 지운 기존 singleton만 복원하며 새 cooldown은 NULL로 시작한다. jdbcTemplate.update("INSERT INTO dining_soldout_report_sequence (id, last_sequence) VALUES (1, 0)"); coopShopFixture.현재학기(); var student = userFixture.준호_학생(departmentFixture.컴퓨터공학부(), null).getUser(); @@ -145,354 +140,353 @@ void tearDown() { } @Test - void 본문과_멱등키_없는_claim은_서비스_인증과_빈_응답을_구분한다() throws Exception { + void 봇_토큰만으로_작업을_배정하고_두_필드_성공_결과로_완료한다() throws Exception { + int reportId = submit(diningId); + JsonNode task = nextClaim(); + assertThat(task.fieldNames()).toIterable() + .containsExactlyInAnyOrder("delivery_id", "attempt_token", "expires_at", "report"); + assertThat(task.path("report").path("report_id").asInt()).isEqualTo(reportId); + assertThat(OffsetDateTime.parse(task.path("expires_at").asText())) + .isEqualTo(START.atZone(KST).toOffsetDateTime().plusSeconds(60)); + postResult(task.path("delivery_id").asText(), objectMapper.writeValueAsString(Map.of( + "attempt_token", task.path("attempt_token").asText(), "outcome", "SUCCEEDED"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_id").value(task.path("delivery_id").asText())) + .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); + emptyClaim(); + } + + @Test + void 서비스_토큰으로만_배정과_결과를_인증하고_빈_작업은_204를_반환한다() throws Exception { mockMvc.perform(post(BOT_PATH + "/deliveries/claim")).andExpect(status().isUnauthorized()); mockMvc.perform(post(BOT_PATH + "/deliveries/claim").header(BOT_HEADER, "wrong-token")) .andExpect(status().isUnauthorized()); mockMvc.perform(post(BOT_PATH + "/deliveries/claim").header("Authorization", "Bearer " + studentToken)) .andExpect(status().isUnauthorized()); emptyClaim(); - int reportId = submit(diningId); + submit(diningId); JsonNode task = nextClaim(); - assertThat(task.path("report").path("report_id").asInt()).isEqualTo(reportId); - assertThat(task.path("mode").asText()).isEqualTo("SEND"); - assertThat(task.path("operation").asText()).isEqualTo("CREATE"); - assertThat(task.path("target").path("workspace_id").asText()).isEqualTo("T_EXAMPLE"); - assertThat(task.path("target").path("channel_id").asText()).isEqualTo("C_EXAMPLE"); - assertThat(task.path("target").path("message_ts").isNull()).isTrue(); - assertThat(OffsetDateTime.parse(task.path("expires_at").asText())) - .isEqualTo(START.atZone(KST).toOffsetDateTime().plusSeconds(60)); emptyClaim(); mockMvc.perform(post(BOT_PATH + "/deliveries/{id}/result", task.path("delivery_id").asText()) - .contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(Map.of( - "attempt_token", task.path("attempt_token").asText(), "outcome", "UNCERTAIN")))) + .contentType(MediaType.APPLICATION_JSON).content(resultBody(task, "SUCCEEDED"))) .andExpect(status().isUnauthorized()); + result(task, "SUCCEEDED").andExpect(status().isOk()); } @Test - void CREATE_성공_재통보는_문자열_ts를_보존하고_제보를_승인하지_않는다() throws Exception { + void 성공_재통보는_멱등이며_제보_업무와_후속_변경을_완료하지_않는다() throws Exception { int reportId = submit(diningId); JsonNode task = nextClaim(); - JsonNode completed = body(result(task, success(MESSAGE_TS)).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED"))); - assertThat(body(result(task, success(MESSAGE_TS)).andExpect(status().isOk()))).isEqualTo(completed); + JsonNode completed = body(result(task, "SUCCEEDED").andExpect(status().isOk())); + assertThat(completed.fieldNames()).toIterable().containsExactlyInAnyOrder("delivery_id", "delivery_state"); + assertThat(completed.path("delivery_state").asText()).isEqualTo("DELIVERED"); + assertThat(body(result(task, "SUCCEEDED").andExpect(status().isOk()))).isEqualTo(completed); assertThat(reportStatus(reportId)).isEqualTo("PENDING"); assertThat(jdbcTemplate.queryForObject("SELECT sold_out FROM dining_menus WHERE id = ?", Object.class, diningId)).isNull(); + result(task, "FAILED").andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); + emptyClaim(); + JsonNode approved = approve(reportId).path("report"); + JsonNode next = nextClaim(); + assertThat(next.path("delivery_id")).isNotEqualTo(task.path("delivery_id")); + assertThat(next.path("report")).isEqualTo(approved); + result(task, "SUCCEEDED").andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); + emptyClaim(); + result(next, "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); - approve(reportId); - JsonNode update = nextClaim(); - assertThat(update.path("operation").asText()).isEqualTo("UPDATE"); - assertThat(update.path("target").path("message_ts").asText()).isEqualTo(MESSAGE_TS); - assertThat(jdbcTemplate.queryForObject( - "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class)).isEqualTo(2L); } @Test - void 최초_claim_전에_승인한_제보는_최신_상태로_CREATE한다() throws Exception { + void 최초_배정_전에_승인해도_생성과_처리_스냅샷을_순서대로_배정한다() throws Exception { int reportId = submit(diningId); JsonNode approved = approve(reportId).path("report"); - JsonNode task = nextClaim(); - assertThat(task.path("operation").asText()).isEqualTo("CREATE"); - assertThat(task.path("report")).isEqualTo(approved); - result(task, success(MESSAGE_TS)).andExpect(status().isOk()); - assertThat(reportStatus(reportId)).isEqualTo("APPROVED"); + JsonNode created = nextClaim(); + assertThat(created.path("report").path("status").asText()).isEqualTo("PENDING"); + assertThat(created.path("report").path("processor").isNull()).isTrue(); + emptyClaim(); + result(created, "SUCCEEDED").andExpect(status().isOk()); + JsonNode processed = nextClaim(); + assertThat(processed.path("delivery_id")).isNotEqualTo(created.path("delivery_id")); + assertThat(processed.path("report")).isEqualTo(approved); + result(processed, "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); } @Test - void 배정_뒤_승인해도_VERIFY는_원본을_확인하고_완료_후_새_UPDATE를_배정한다() throws Exception { + void 실패는_5초_후_같은_작업을_재배정하고_재통보가_대기를_연장하지_않는다() throws Exception { int reportId = submit(diningId); - JsonNode send = nextClaim(); + JsonNode task = nextClaim(); JsonNode approved = approve(reportId).path("report"); + result(task, "FAILED").andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value("QUEUED")); + advance(4); + result(task, "FAILED").andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value("QUEUED")); + result(task, "SUCCEEDED").andExpect(status().isConflict()); emptyClaim(); - result(send, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()); - JsonNode verify = nextClaim(); - assertSameWork(send, verify, "VERIFY"); - assertThat(verify.path("report").path("status").asText()).isEqualTo("PENDING"); - result(verify, success(MESSAGE_TS)).andExpect(status().isOk()); - JsonNode update = nextClaim(); - assertThat(update.path("delivery_id")).isNotEqualTo(send.path("delivery_id")); - assertThat(update.path("operation").asText()).isEqualTo("UPDATE"); - assertThat(update.path("report")).isEqualTo(approved); - assertThat(update.path("target").path("message_ts").asText()).isEqualTo(MESSAGE_TS); - result(update, Map.of("outcome", "NOT_APPLIED", "reason", "REJECTED", "error_code", "message_not_found")) - .andExpect(status().isOk()).andExpect(jsonPath("$.delivery_state").value("NEEDS_ATTENTION")); - advance(600); + advance(1); + JsonNode retry = nextClaim(); + assertSameWork(task, retry); + result(task, "FAILED").andExpect(status().isConflict()); + result(task, "SUCCEEDED").andExpect(status().isConflict()); + result(retry, "SUCCEEDED").andExpect(status().isOk()); + JsonNode processed = nextClaim(); + assertThat(processed.path("report")).isEqualTo(approved); + result(processed, "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); - assertThat(reportStatus(reportId)).isEqualTo("APPROVED"); } - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void SEND_60초_만료와_VERIFY_실패는_재전송_없이_5분_뒤_다시_확인한다(boolean verifyExpires) throws Exception { - submit(diningId); - JsonNode send = nextClaim(); - advance(59); - emptyClaim(); - advance(1); - JsonNode verify = nextClaim(); - assertSameWork(send, verify, "VERIFY"); - if (verifyExpires) { - advance(59); - emptyClaim(); - advance(1); - emptyClaim(); - } else { - result(verify, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("NEEDS_ATTENTION")); - } - advance(299); + @Test + void 한_제보의_실패_대기는_다른_제보의_배정을_막지_않는다() throws Exception { + int firstReportId = submit(diningId); + int secondReportId = submit(otherDiningId); + JsonNode first = nextClaim(); + assertThat(first.path("report").path("report_id").asInt()).isEqualTo(firstReportId); + result(first, "FAILED").andExpect(status().isOk()); + approve(firstReportId); + JsonNode second = nextClaim(); + assertThat(second.path("report").path("report_id").asInt()).isEqualTo(secondReportId); + result(second, "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); - advance(1); - assertSameWork(send, nextClaim(), "VERIFY"); + advance(5); + assertSameWork(first, nextClaim()); } @Test - void 늦은_SEND_성공은_VERIFY를_무효화하고_이후_불확실한_결과와_만료에도_유지한다() throws Exception { - submit(diningId); - JsonNode send = nextClaim(); - advance(60); - JsonNode verify = nextClaim(); - result(send, success(MESSAGE_TS)).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); - result(verify, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); - advance(600); + void 구버전의_늦은_변경_INSERT는_첫_claim에서_UUID와_스냅샷을_초기화한다() throws Exception { + int existingReportId = submit(diningId); + result(nextClaim(), "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); - result(send, success(MESSAGE_TS)).andExpect(status().isOk()) + int legacyReportId = new TransactionTemplate(transactionManager).execute(transaction -> { + jdbcTemplate.update(""" + INSERT INTO dining_soldout_report + (dining_id, reporter_id, image_url, status, request_key, created_at, updated_at) + SELECT ?, reporter_id, image_url, 'PENDING', UNHEX(REPLACE(?, '-', '')), created_at, updated_at + FROM dining_soldout_report WHERE id = ? + """, otherDiningId, UUID.randomUUID().toString(), existingReportId); + int reportId = jdbcTemplate.queryForObject( + "SELECT id FROM dining_soldout_report WHERE dining_id = ?", Integer.class, otherDiningId); + long sequence = sequenceRepository.findForUpdate().next(); + jdbcTemplate.update(""" + INSERT INTO dining_soldout_report_change + (sequence, report_id, event_type, status, processing_type, processing_id, occurred_at) + VALUES (?, ?, 'CREATED', 'PENDING', NULL, NULL, ?) + """, sequence, reportId, now.get().atZone(KST).toLocalDateTime()); + return reportId; + }); + assertThat(jdbcTemplate.queryForObject( + "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class)).isEqualTo(2L); + assertThat(jdbcTemplate.queryForMap(""" + SELECT delivery_id, report_snapshot, delivery_state, attempt_token, expires_at, next_attempt_at, accepted_outcome + FROM dining_soldout_report_change WHERE report_id = ? + """, legacyReportId)) + .containsEntry("delivery_id", null).containsEntry("report_snapshot", null) + .containsEntry("delivery_state", "QUEUED").containsEntry("attempt_token", null) + .containsEntry("expires_at", null).containsEntry("next_attempt_at", null).containsEntry("accepted_outcome", null); + JsonNode expectedReport = body(mockMvc.perform(get(BOT_PATH + "/{id}", legacyReportId) + .header(BOT_HEADER, BOT_TOKEN)).andExpect(status().isOk())); + JsonNode task = nextClaim(); + String deliveryId = task.path("delivery_id").asText(); + assertThat(UUID.fromString(deliveryId).toString()).isEqualTo(deliveryId); + assertThat(task.path("report")).isEqualTo(expectedReport); + String storedSnapshot = jdbcTemplate.queryForObject(""" + SELECT report_snapshot FROM dining_soldout_report_change + WHERE report_id = ? AND delivery_id = UNHEX(REPLACE(?, '-', '')) + """, String.class, legacyReportId, deliveryId); + assertThat(objectMapper.readTree(storedSnapshot)).isEqualTo(expectedReport); + result(task, "SUCCEEDED").andExpect(status().isOk()) .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); + emptyClaim(); + JsonNode approved = approve(legacyReportId).path("report"); + assertThat(jdbcTemplate.queryForObject( + "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class)).isEqualTo(3L); + JsonNode processed = nextClaim(); + assertThat(processed.path("report")).isEqualTo(approved); + result(processed, "SUCCEEDED").andExpect(status().isOk()); + emptyClaim(); + } + + @Test + void 배정_60초_만료_즉시_새_토큰을_배정하고_이전_토큰의_결과는_거부한다() throws Exception { + int reportId = submit(diningId); + JsonNode first = nextClaim(); + JsonNode approved = approve(reportId).path("report"); + advance(59); + emptyClaim(); + advance(1); + JsonNode replacement = nextClaim(); + assertSameWork(first, replacement); + assertThat(OffsetDateTime.parse(replacement.path("expires_at").asText())) + .isEqualTo(START.atZone(KST).toOffsetDateTime().plusSeconds(120)); + for (String outcome : List.of("SUCCEEDED", "FAILED")) { + result(first, outcome).andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); + } + emptyClaim(); + result(replacement, "SUCCEEDED").andExpect(status().isOk()); + JsonNode processed = nextClaim(); + assertThat(processed.path("report")).isEqualTo(approved); + result(processed, "SUCCEEDED").andExpect(status().isOk()); + emptyClaim(); } @ParameterizedTest - @ValueSource(strings = {"NOT_SENT", "RATE_LIMITED", "REJECTED"}) - void VERIFY_만료_후_늦은_SEND_미반영_증거는_원인에_맞게_복구한다(String reason) throws Exception { + @ValueSource(strings = {"SUCCEEDED", "FAILED"}) + void 만료되어도_재배정되지_않은_현재_토큰의_늦은_결과를_접수한다(String outcome) throws Exception { submit(diningId); - JsonNode send = nextClaim(); - advance(60); - JsonNode verify = nextClaim(); - advance(60); + JsonNode task = nextClaim(); + advance(61); + String expectedState = outcome.equals("SUCCEEDED") ? "DELIVERED" : "QUEUED"; + result(task, outcome).andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value(expectedState)); + result(task, outcome).andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value(expectedState)); + result(task, outcome.equals("SUCCEEDED") ? "FAILED" : "SUCCEEDED").andExpect(status().isConflict()); emptyClaim(); - Map evidence = notApplied(reason, 30); - String expected = reason.equals("REJECTED") ? "NEEDS_ATTENTION" : "QUEUED"; - result(send, evidence).andExpect(status().isOk()).andExpect(jsonPath("$.delivery_state").value(expected)); - result(verify, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value(expected)); - result(send, evidence).andExpect(status().isOk()); - if (reason.equals("REJECTED")) { - advance(600); - emptyClaim(); - } else { - if (reason.equals("RATE_LIMITED")) { - advance(29); - emptyClaim(); - advance(1); - } - assertSameWork(send, nextClaim(), "SEND"); + if (outcome.equals("FAILED")) { + advance(5); + JsonNode retry = nextClaim(); + assertSameWork(task, retry); + result(retry, "SUCCEEDED").andExpect(status().isOk()); } + emptyClaim(); } @Test - void 요청제한은_다른_제보에도_적용되고_더_긴_대기와_재통보를_보존한다() throws Exception { + void 없는_작업과_다른_작업의_토큰은_404이고_알_수_없는_현재_토큰은_409이다() throws Exception { submit(diningId); submit(otherDiningId); JsonNode first = nextClaim(); JsonNode second = nextClaim(); - result(first, notApplied("RATE_LIMITED", 30)).andExpect(status().isOk()); - result(second, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()); - JsonNode verify = nextClaim(); - assertSameWork(second, verify, "VERIFY"); - advance(10); - result(second, notApplied("RATE_LIMITED", 60)).andExpect(status().isOk()); - result(verify, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("QUEUED")); - result(first, notApplied("RATE_LIMITED", 30)).andExpect(status().isOk()); - assertThat(jdbcTemplate.queryForObject( - "SELECT delivery_cooldown_until FROM dining_soldout_report_sequence WHERE id = 1", - LocalDateTime.class)).isEqualTo(START.plusSeconds(70).atZone(KST).toLocalDateTime()); - advance(59); - emptyClaim(); - advance(1); - JsonNode retry = nextClaim(); - assertThat(retry.path("mode").asText()).isEqualTo("SEND"); - assertThat(retry.path("report").path("report_id")) - .isIn(first.path("report").path("report_id"), second.path("report").path("report_id")); - } - - @Test - void 보류된_제보의_늦은_요청제한도_다른_전송을_막고_재통보로_연장하지_않는다() throws Exception { - int heldReportId = submit(diningId); - int otherReportId = submit(otherDiningId); - JsonNode send = nextClaim(); - assertThat(send.path("report").path("report_id").asInt()).isEqualTo(heldReportId); - result(send, Map.of("outcome", "SUCCEEDED", "message_ref", - Map.of("channel_id", "C_OTHER", "message_ts", MESSAGE_TS))) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); - Map heldBinding = binding(heldReportId); - assertThat(heldBinding).containsEntry("workspace_id", "T_EXAMPLE") - .containsEntry("channel_id", "C_EXAMPLE").containsEntry("message_ts", null) - .containsEntry("hold_reason", "CONFLICT"); - String attemptOutcomeSql = """ - SELECT accepted_outcome FROM dining_soldout_report_delivery_attempt - WHERE token = UNHEX(REPLACE(?, '-', '')) - """; - assertThat(jdbcTemplate.queryForObject(attemptOutcomeSql, String.class, - send.path("attempt_token").asText())).isNull(); - - Map rateLimited = notApplied("RATE_LIMITED", 30); - LocalDateTime deadline = now.get().atZone(KST).toLocalDateTime().plusSeconds(30); - String cooldownSql = "SELECT delivery_cooldown_until FROM dining_soldout_report_sequence WHERE id = 1"; - result(send, rateLimited).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("NEEDS_ATTENTION")); - assertThat(jdbcTemplate.queryForObject(attemptOutcomeSql, String.class, - send.path("attempt_token").asText())).isEqualTo("NOT_APPLIED"); - assertThat(jdbcTemplate.queryForObject(cooldownSql, LocalDateTime.class)).isEqualTo(deadline); - emptyClaim(); - - advance(10); - result(send, rateLimited).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("NEEDS_ATTENTION")); - assertThat(jdbcTemplate.queryForObject(cooldownSql, LocalDateTime.class)).isEqualTo(deadline); - assertThat(binding(heldReportId)).isEqualTo(heldBinding); - advance(19); + postResult(UUID.randomUUID().toString(), resultBody(first, "SUCCEEDED")) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_FOUND_DINING_REPORT_DELIVERY")); + postResult(first.path("delivery_id").asText(), resultBody(second, "SUCCEEDED")) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_FOUND_DINING_REPORT_DELIVERY")); + postResult(first.path("delivery_id").asText(), objectMapper.writeValueAsString(Map.of( + "attempt_token", UUID.randomUUID().toString(), "outcome", "SUCCEEDED"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); + result(first, "SUCCEEDED").andExpect(status().isOk()); + result(second, "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); - advance(1); - JsonNode next = nextClaim(); - assertThat(next.path("report").path("report_id").asInt()).isEqualTo(otherReportId); - assertThat(next.path("mode").asText()).isEqualTo("SEND"); - assertThat(binding(heldReportId)).isEqualTo(heldBinding); } @ParameterizedTest - @ValueSource(strings = {"different_ts", "wrong_channel", "not_applied", "update_target"}) - void 충돌_409는_기존_메시지_연결을_보존하고_다음_트랜잭션에도_전송을_보류한다(String conflict) throws Exception { - int reportId = submit(diningId); - JsonNode send = nextClaim(); - result(send, success(MESSAGE_TS)).andExpect(status().isOk()); - JsonNode task = send; - if (conflict.equals("update_target")) { - approve(reportId); - task = nextClaim(); - } - Map evidence = switch (conflict) { - case "not_applied" -> notApplied("NOT_SENT", 0); - case "wrong_channel" -> Map.of("outcome", "SUCCEEDED", "message_ref", - Map.of("channel_id", "C_OTHER", "message_ts", MESSAGE_TS)); - default -> success("1791257400.000101"); - }; - result(task, evidence).andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); - assertThat(binding(reportId)).containsEntry("workspace_id", "T_EXAMPLE") - .containsEntry("channel_id", "C_EXAMPLE").containsEntry("message_ts", MESSAGE_TS) - .containsEntry("hold_reason", "CONFLICT"); - result(send, success(MESSAGE_TS)).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("NEEDS_ATTENTION")); - if (!conflict.equals("update_target")) { - approve(reportId); + @ValueSource(strings = {"message_ref", "reason", "error_code", "retry_after_seconds", "mode", "operation", "target", "unexpected"}) + void 결과의_미지원_필드는_null이어도_거부한다(String field) throws Exception { + submit(diningId); + JsonNode task = nextClaim(); + for (String outcome : List.of("SUCCEEDED", "FAILED")) { + for (Object value : new Object[] {null, true}) { + Map request = new LinkedHashMap<>(); + request.put("attempt_token", task.path("attempt_token").asText()); + request.put("outcome", outcome); + request.put(field, value); + postResult(task.path("delivery_id").asText(), objectMapper.writeValueAsString(request)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } } - advance(600); - emptyClaim(); - assertThat(reportStatus(reportId)).isEqualTo("APPROVED"); + result(task, "SUCCEEDED").andExpect(status().isOk()); } @Test - void 결과는_엄격한_본문_변형과_시도_소유권을_검증한다() throws Exception { + void 결과는_필수_문자열과_두_결과값과_정규_UUID를_검증한다() throws Exception { submit(diningId); JsonNode task = nextClaim(); - Map>> invalidBodies = Map.of( + String deliveryId = task.path("delivery_id").asText(); + String token = task.path("attempt_token").asText(); + Map> invalidBodies = Map.of( "INVALID_REQUEST_BODY", List.of( - Map.of("outcome", "SUCCEEDED"), - Map.of("outcome", "NOT_APPLIED"), - Map.of("outcome", "NOT_APPLIED", "reason", "RATE_LIMITED"), - notApplied("RATE_LIMITED", 0), - Map.of("outcome", "NOT_APPLIED", "reason", "REJECTED", "error_code", ""), - Map.of("outcome", "NOT_APPLIED", "reason", "REJECTED", "error_code", "x".repeat(129)), - Map.of("outcome", "UNCERTAIN", "message_ref", Map.of("channel_id", "C_EXAMPLE", "message_ts", MESSAGE_TS)), - Map.of("outcome", "UNCERTAIN", "reason", "NOT_SENT"), - Map.of("outcome", "UNCERTAIN", "unexpected", true)), + "{}", "{\"outcome\":\"SUCCEEDED\"}", + "{\"attempt_token\":null,\"outcome\":\"SUCCEEDED\"}", + "{\"attempt_token\":\"\",\"outcome\":\"SUCCEEDED\"}", + "{\"attempt_token\":\"%s\"}".formatted(token), + "{\"attempt_token\":\"%s\",\"outcome\":null}".formatted(token), + "{\"attempt_token\":\"%s\",\"outcome\":\"\"}".formatted(token)), "NOT_READABLE_HTTP_MESSAGE", List.of( - Map.of("outcome", "SUCCEEDED", "message_ref", Map.of("channel_id", "C_EXAMPLE", "message_ts", 123.5)), - Map.of("outcome", "NOT_APPLIED", "reason", "RATE_LIMITED", "retry_after_seconds", 1.5))); + "null", "[]", "true", "{\"attempt_token\":123,\"outcome\":\"SUCCEEDED\"}", + "{\"attempt_token\":\"not-a-uuid\",\"outcome\":\"SUCCEEDED\"}", + "{\"attempt_token\":\"1-1-1-1-1\",\"outcome\":\"SUCCEEDED\"}", + "{\"attempt_token\":\"%s\",\"outcome\":true}".formatted(token), + "{\"attempt_token\":\"%s\",\"outcome\":\"UNCERTAIN\"}".formatted(token), + "{\"attempt_token\":\"%s\",\"outcome\":\"NOT_APPLIED\"}".formatted(token), + "{\"attempt_token\":\"%s\",\"outcome\":\"succeeded\"}".formatted(token))); for (var invalid : invalidBodies.entrySet()) { - for (var request : invalid.getValue()) { - result(task, request).andExpect(status().isBadRequest()) + for (String request : invalid.getValue()) { + postResult(deliveryId, request).andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(invalid.getKey())); } } - String deliveryId = task.path("delivery_id").asText(); - postResult(deliveryId, "{\"outcome\":\"UNCERTAIN\"}").andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); - postResult(deliveryId, """ - {"attempt_token":"%s","outcome":"UNCERTAIN","reason":null} - """.formatted(task.path("attempt_token").asText())) - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); - String validBody = objectMapper.writeValueAsString(Map.of( - "attempt_token", task.path("attempt_token").asText(), "outcome", "UNCERTAIN")); - JsonNode error = body(postResult("not-a-uuid", validBody).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("ILLEGAL_ARGUMENT")) - .andExpect(jsonPath("$.status").doesNotHaveJsonPath())); - assertThat(error.path("message").asText()).isNotBlank(); - assertThat(UUID.fromString(error.path("errorTraceId").asText()).toString()) - .isEqualTo(error.path("errorTraceId").asText()); - postResult("1-1-1-1-1", validBody).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("ILLEGAL_ARGUMENT")); - postResult(deliveryId, "{\"attempt_token\":\"not-a-uuid\",\"outcome\":\"UNCERTAIN\"}") - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("NOT_READABLE_HTTP_MESSAGE")); - String unknownAttempt = objectMapper.writeValueAsString(Map.of( - "attempt_token", UUID.randomUUID().toString(), "outcome", "UNCERTAIN")); - postResult(deliveryId, unknownAttempt).andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("NOT_FOUND_DINING_REPORT_DELIVERY")); - postResult(UUID.randomUUID().toString(), unknownAttempt).andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("NOT_FOUND_DINING_REPORT_DELIVERY")); - result(task, Map.of("outcome", "UNCERTAIN")).andExpect(status().isOk()); - JsonNode verify = nextClaim(); - result(verify, notApplied("NOT_SENT", 0)).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); - result(verify, success(MESSAGE_TS)).andExpect(status().isOk()); + for (String invalidId : List.of("not-a-uuid", "1-1-1-1-1")) { + JsonNode error = body(postResult(invalidId, resultBody(task, "SUCCEEDED")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("ILLEGAL_ARGUMENT")) + .andExpect(jsonPath("$.status").doesNotHaveJsonPath())); + assertThat(error.path("message").asText()).isNotBlank(); + assertThat(UUID.fromString(error.path("errorTraceId").asText()).toString()) + .isEqualTo(error.path("errorTraceId").asText()); + } + result(task, "SUCCEEDED").andExpect(status().isOk()); } @Test - void 동시_claim은_한_제보에_SEND를_한번만_배정한다() throws Exception { + void 동시_claim은_하나의_작업에_하나의_시도만_배정한다() throws Exception { int reportId = submit(diningId); List responses = concurrentlyAtGate(() -> claim().andReturn(), () -> claim().andReturn()); assertThat(responses).extracting(response -> response.getResponse().getStatus()) .containsExactlyInAnyOrder(200, 204); JsonNode task = body(responses.stream().filter(response -> response.getResponse().getStatus() == 200) .findFirst().orElseThrow()); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM dining_soldout_report_delivery WHERE report_id = ?", Integer.class, reportId)).isOne(); assertThat(jdbcTemplate.queryForObject(""" - SELECT COUNT(*) FROM dining_soldout_report_delivery_attempt a - JOIN dining_soldout_report_delivery d ON d.id = a.delivery_id WHERE d.report_id = ? - """, Integer.class, reportId)).isOne(); + SELECT COUNT(*) FROM dining_soldout_report_change + WHERE report_id = ? AND delivery_state = 'IN_PROGRESS' + AND delivery_id = UNHEX(REPLACE(?, '-', '')) AND attempt_token = UNHEX(REPLACE(?, '-', '')) + """, Integer.class, reportId, task.path("delivery_id").asText(), task.path("attempt_token").asText())).isOne(); emptyClaim(); - result(task, success(MESSAGE_TS)).andExpect(status().isOk()); + result(task, "SUCCEEDED").andExpect(status().isOk()); emptyClaim(); } @ParameterizedTest @ValueSource(booleans = {false, true}) - void 동시_성공_증거는_같으면_멱등이고_다르면_연결을_보존하며_보류한다(boolean conflicting) throws Exception { + void 동시_결과는_같으면_멱등이고_다르면_409이며_후속_작업을_보류하지_않는다(boolean conflicting) throws Exception { int reportId = submit(diningId); JsonNode task = nextClaim(); List responses = concurrentlyAtGate( - () -> result(task, success(MESSAGE_TS)).andReturn(), - () -> result(task, success(conflicting ? "1791257400.000101" : MESSAGE_TS)).andReturn()); + () -> result(task, "SUCCEEDED").andReturn(), + () -> result(task, conflicting ? "FAILED" : "SUCCEEDED").andReturn()); assertThat(responses).extracting(response -> response.getResponse().getStatus()) .containsExactlyInAnyOrder(200, conflicting ? 409 : 200); - String winningTs = responses.get(0).getResponse().getStatus() == 200 ? MESSAGE_TS : "1791257400.000101"; - assertThat(binding(reportId)).containsEntry("message_ts", winningTs); + result(task, "SUCCEEDED").andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); assertThat(reportStatus(reportId)).isEqualTo("PENDING"); - approve(reportId); - if (conflicting) { - assertThat(binding(reportId)).containsEntry("hold_reason", "CONFLICT"); - emptyClaim(); - } else { - JsonNode update = nextClaim(); - assertThat(update.path("operation").asText()).isEqualTo("UPDATE"); - assertThat(update.path("target").path("message_ts").asText()).isEqualTo(MESSAGE_TS); - } + JsonNode approved = approve(reportId).path("report"); + JsonNode processed = nextClaim(); + assertThat(processed.path("report")).isEqualTo(approved); + result(processed, "SUCCEEDED").andExpect(status().isOk()); + emptyClaim(); + } + + @ParameterizedTest + @ValueSource(strings = {"/v3/api-docs", "/v3/api-docs/3. Campus API"}) + void 기본과_Campus_스키마는_단순한_닫힌_작업_객체와_해결된_참조를_제공한다(String path) throws Exception { + JsonNode document = body(mockMvc.perform(get(path)).andExpect(status().isOk())); + JsonNode schemas = document.path("components").path("schemas"); + assertClosedSchema(schemas.path("DiningReportDeliveryResponse"), + "delivery_id", "attempt_token", "expires_at", "report"); + assertClosedSchema(schemas.path("DiningReportDeliveryResultRequest"), "attempt_token", "outcome"); + assertClosedSchema(schemas.path("DiningReportDeliveryResultResponse"), "delivery_id", "delivery_state"); + assertThat(schemas.path("DiningReportDeliveryResultRequest").path("properties").path("outcome").path("enum")) + .isEqualTo(objectMapper.valueToTree(List.of("SUCCEEDED", "FAILED"))); + assertThat(schemas.path("DiningReportDeliveryResultResponse").path("properties").path("delivery_state").path("enum")) + .isEqualTo(objectMapper.valueToTree(List.of("QUEUED", "IN_PROGRESS", "DELIVERED"))); + assertThat(schemas.fieldNames()).toIterable().doesNotContain("DiningReportDeliveryMessageTarget", + "DiningReportDeliveryMessageReference", "DiningReportDeliverySucceeded", "DiningReportDeliveryNotSent", + "DiningReportDeliveryRateLimited", "DiningReportDeliveryRejected", "DiningReportDeliveryUncertain"); + List references = new ArrayList<>(); + collectReferences(document, references); + assertThat(references.stream().filter(reference -> reference.startsWith("#/components/schemas/")) + .filter(reference -> document.at(reference.substring(1)).isMissingNode()).distinct().toList()).isEmpty(); } private int submit(Integer targetDiningId) throws Exception { @@ -524,10 +518,13 @@ private void emptyClaim() throws Exception { .andExpect(content().string("")); } - private ResultActions result(JsonNode task, Map evidence) throws Exception { - Map request = new LinkedHashMap<>(evidence); - request.put("attempt_token", task.path("attempt_token").asText()); - return postResult(task.path("delivery_id").asText(), objectMapper.writeValueAsString(request)); + private ResultActions result(JsonNode task, String outcome) throws Exception { + return postResult(task.path("delivery_id").asText(), resultBody(task, outcome)); + } + + private String resultBody(JsonNode task, String outcome) throws Exception { + return objectMapper.writeValueAsString(Map.of( + "attempt_token", task.path("attempt_token").asText(), "outcome", outcome)); } private ResultActions postResult(String deliveryId, String request) throws Exception { @@ -535,25 +532,26 @@ private ResultActions postResult(String deliveryId, String request) throws Excep .contentType(MediaType.APPLICATION_JSON).content(request)); } - private Map success(String messageTs) { - return Map.of("outcome", "SUCCEEDED", "message_ref", Map.of("channel_id", "C_EXAMPLE", "message_ts", messageTs)); + private void assertSameWork(JsonNode original, JsonNode next) { + assertThat(next.path("delivery_id")).isEqualTo(original.path("delivery_id")); + assertThat(next.path("attempt_token")).isNotEqualTo(original.path("attempt_token")); + assertThat(next.path("report")).isEqualTo(original.path("report")); } - private Map notApplied(String reason, long retryAfter) { - return switch (reason) { - case "RATE_LIMITED" -> Map.of("outcome", "NOT_APPLIED", "reason", reason, "retry_after_seconds", retryAfter); - case "REJECTED" -> Map.of("outcome", "NOT_APPLIED", "reason", reason, "error_code", "channel_not_found"); - default -> Map.of("outcome", "NOT_APPLIED", "reason", reason); - }; + private void assertClosedSchema(JsonNode schema, String... fields) { + assertThat(schema.path("type").asText()).isEqualTo("object"); + assertThat(schema.path("additionalProperties").asBoolean(true)).isFalse(); + assertThat(schema.path("properties").fieldNames()).toIterable().containsExactlyInAnyOrder(fields); + assertThat(objectMapper.convertValue(schema.path("required"), String[].class)).containsExactlyInAnyOrder(fields); + assertThat(schema.has("oneOf")).isFalse(); + assertThat(schema.has("anyOf")).isFalse(); } - private void assertSameWork(JsonNode original, JsonNode next, String mode) { - assertThat(next.path("delivery_id")).isEqualTo(original.path("delivery_id")); - assertThat(next.path("attempt_token")).isNotEqualTo(original.path("attempt_token")); - assertThat(next.path("mode").asText()).isEqualTo(mode); - assertThat(next.path("operation")).isEqualTo(original.path("operation")); - assertThat(next.path("target")).isEqualTo(original.path("target")); - assertThat(next.path("report")).isEqualTo(original.path("report")); + private void collectReferences(JsonNode node, List references) { + if (node.isObject() && node.has("$ref")) { + references.add(node.path("$ref").asText()); + } + node.elements().forEachRemaining(child -> collectReferences(child, references)); } private void advance(long seconds) { @@ -564,13 +562,6 @@ private String reportStatus(int reportId) { return jdbcTemplate.queryForObject("SELECT status FROM dining_soldout_report WHERE id = ?", String.class, reportId); } - private Map binding(int reportId) { - return jdbcTemplate.queryForMap(""" - SELECT workspace_id, channel_id, message_ts, hold_reason - FROM dining_soldout_report_delivery_target WHERE report_id = ? - """, reportId); - } - private JsonNode body(ResultActions response) throws Exception { return body(response.andReturn()); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java index f31e331bf..8ecd312fd 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java @@ -13,10 +13,8 @@ import java.time.LocalDate; import java.time.ZoneId; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -44,9 +42,7 @@ import in.koreatech.koin.infrastructure.s3.client.S3Client; @TestPropertySource(properties = { - "dining.report.bot-token=test-dining-report-bot-token", - "dining.report.delivery.workspace-id=T_EXAMPLE", - "dining.report.delivery.channel-id=C_EXAMPLE" + "dining.report.bot-token=test-dining-report-bot-token" }) class DiningSoldOutReportApiTest extends AcceptanceTest { @@ -357,92 +353,26 @@ void setUp() { private void assertClaimSchema(JsonNode api, JsonNode claim) { JsonNode response = contentSchema(api, claim.at("/responses/200/content")); - assertThat(response.path("oneOf")).hasSize(2); - Set operations = new HashSet<>(); - for (JsonNode branch : response.path("oneOf")) { - JsonNode work = schema(api, branch); - assertClosedObject(work, "delivery_id", "attempt_token", "mode", "operation", "expires_at", "target", "report"); - JsonNode properties = work.path("properties"); - assertUuid(properties.path("delivery_id")); - assertUuid(properties.path("attempt_token")); - assertThat(schema(api, properties.path("mode")).path("enum")).extracting(JsonNode::asText) - .containsExactlyInAnyOrder("SEND", "VERIFY"); - JsonNode operation = schema(api, properties.path("operation")).path("enum"); - assertThat(operation).hasSize(1); - String name = operation.get(0).asText(); - assertThat(name).isIn("CREATE", "UPDATE"); - operations.add(name); - assertThat(properties.path("expires_at").path("format").asText()).isEqualTo("date-time"); - assertThat(schema(api, properties.path("report")).path("properties").has("report_id")).isTrue(); - - JsonNode target = schema(api, properties.path("target")); - assertClosedObject(target, "workspace_id", "channel_id", "message_ts"); - for (String field : List.of("workspace_id", "channel_id")) { - assertThat(target.path("properties").path(field).path("type").asText()).isEqualTo("string"); - assertThat(target.path("properties").path(field).path("minLength").asInt()).isEqualTo(1); - } - JsonNode timestamp = target.path("properties").path("message_ts"); - assertThat(timestamp.path("type").asText()).isEqualTo("string"); - if (name.equals("CREATE")) { - assertThat(timestamp.path("nullable").asBoolean()).isTrue(); - assertThat(timestamp.path("enum")).hasSize(1); - assertThat(timestamp.path("enum").get(0).isNull()).isTrue(); - } else { - assertThat(timestamp.path("nullable").asBoolean()).isFalse(); - assertThat(timestamp.path("minLength").asInt()).isEqualTo(1); - } - } - assertThat(operations).containsExactlyInAnyOrder("CREATE", "UPDATE"); + assertClosedObject(response, "delivery_id", "attempt_token", "expires_at", "report"); + JsonNode properties = response.path("properties"); + assertUuid(properties.path("delivery_id")); + assertUuid(properties.path("attempt_token")); + assertThat(properties.path("expires_at").path("format").asText()).isEqualTo("date-time"); + assertThat(schema(api, properties.path("report")).path("properties").has("report_id")).isTrue(); } private void assertResultSchemas(JsonNode api) { JsonNode operation = api.path("paths").path(BOT_PATH + "/deliveries/{deliveryId}/result").path("post"); JsonNode request = contentSchema(api, operation.at("/requestBody/content")); - Map> fields = Map.of( - "SUCCEEDED", List.of("attempt_token", "outcome", "message_ref"), - "NOT_SENT", List.of("attempt_token", "outcome", "reason"), - "RATE_LIMITED", List.of("attempt_token", "outcome", "reason", "retry_after_seconds"), - "REJECTED", List.of("attempt_token", "outcome", "reason", "error_code"), - "UNCERTAIN", List.of("attempt_token", "outcome")); - assertThat(request.path("oneOf")).hasSize(fields.size()); - Set variants = new HashSet<>(); - for (JsonNode branch : request.path("oneOf")) { - JsonNode result = schema(api, branch); - JsonNode properties = result.path("properties"); - JsonNode outcomeValues = schema(api, properties.path("outcome")).path("enum"); - assertThat(outcomeValues).hasSize(1); - String outcome = outcomeValues.get(0).asText(); - assertThat(outcome).isIn("SUCCEEDED", "NOT_APPLIED", "UNCERTAIN"); - String variant = outcome; - if (outcome.equals("NOT_APPLIED")) { - JsonNode reasons = schema(api, properties.path("reason")).path("enum"); - assertThat(reasons).hasSize(1); - variant = reasons.get(0).asText(); - assertThat(variant).isIn("NOT_SENT", "RATE_LIMITED", "REJECTED"); - } - variants.add(variant); - assertClosedObject(result, fields.get(variant).toArray(String[]::new)); - assertUuid(properties.path("attempt_token")); - if (variant.equals("SUCCEEDED")) { - JsonNode reference = schema(api, properties.path("message_ref")); - assertClosedObject(reference, "channel_id", "message_ts"); - for (String field : List.of("channel_id", "message_ts")) { - assertThat(reference.path("properties").path(field).path("type").asText()).isEqualTo("string"); - assertThat(reference.path("properties").path(field).path("minLength").asInt()).isEqualTo(1); - } - } else if (variant.equals("RATE_LIMITED")) { - assertThat(properties.path("retry_after_seconds").path("type").asText()).isEqualTo("integer"); - assertThat(properties.path("retry_after_seconds").path("minimum").asInt()).isEqualTo(1); - } else if (variant.equals("REJECTED")) { - assertThat(properties.path("error_code").path("type").asText()).isEqualTo("string"); - assertThat(properties.path("error_code").path("minLength").asInt()).isEqualTo(1); - assertThat(properties.path("error_code").path("maxLength").asInt()).isEqualTo(128); - } - } - assertThat(variants).containsExactlyInAnyOrderElementsOf(fields.keySet()); + assertClosedObject(request, "attempt_token", "outcome"); + assertUuid(request.path("properties").path("attempt_token")); + assertThat(schema(api, request.path("properties").path("outcome")).path("enum")) + .extracting(JsonNode::asText).containsExactlyInAnyOrder("SUCCEEDED", "FAILED"); JsonNode response = contentSchema(api, operation.at("/responses/200/content")); assertClosedObject(response, "delivery_id", "delivery_state"); assertUuid(response.path("properties").path("delivery_id")); + assertThat(schema(api, response.path("properties").path("delivery_state")).path("enum")) + .extracting(JsonNode::asText).containsExactlyInAnyOrder("QUEUED", "IN_PROGRESS", "DELIVERED"); } private JsonNode contentSchema(JsonNode api, JsonNode content) { diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java index b688f0c14..2e293eb32 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java @@ -71,9 +71,7 @@ @Transactional(propagation = Propagation.NOT_SUPPORTED) @TestPropertySource(properties = { - "dining.report.bot-token=test-dining-report-bot-token", - "dining.report.delivery.workspace-id=T_EXAMPLE", - "dining.report.delivery.channel-id=C_EXAMPLE" + "dining.report.bot-token=test-dining-report-bot-token" }) class DiningSoldOutReportConcurrencyTest extends AcceptanceTest { @@ -325,15 +323,18 @@ SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) assertThat(changes).extracting(row -> row.get("status")).containsOnly(expectedStatus); assertThat(changes).extracting(row -> row.get("processing_type")).containsOnly(expectedProcessingType); assertThat(lastSequence()).isEqualTo(sequenceBefore + 2); - List> targets = jdbcTemplate.queryForList(""" - SELECT report_id, desired_sequence, JSON_UNQUOTE(JSON_EXTRACT(desired_snapshot, '$.status')) AS status - FROM dining_soldout_report_delivery_target ORDER BY desired_sequence + List> tasks = jdbcTemplate.queryForList(""" + SELECT report_id, sequence, delivery_id, delivery_state, + JSON_UNQUOTE(JSON_EXTRACT(report_snapshot, '$.status')) AS status + FROM dining_soldout_report_change WHERE event_type = 'PROCESSED' ORDER BY sequence """); - assertThat(targets).extracting(row -> ((Number)row.get("report_id")).intValue()) + assertThat(tasks).extracting(row -> ((Number)row.get("report_id")).intValue()) .containsExactly(secondReportId, firstReportId); - assertThat(targets).extracting(row -> ((Number)row.get("desired_sequence")).longValue()) + assertThat(tasks).extracting(row -> ((Number)row.get("sequence")).longValue()) .containsExactly(sequenceBefore + 1, sequenceBefore + 2); - assertThat(targets).extracting(row -> row.get("status")).containsOnly(expectedStatus); + assertThat(tasks).extracting(row -> row.get("status")).containsOnly(expectedStatus); + assertThat(tasks).extracting(row -> row.get("delivery_state")).containsOnly("QUEUED"); + assertThat(tasks).allSatisfy(row -> assertThat(row.get("delivery_id")).isNotNull()); } finally { firstAtSequence.countDown(); secondLockedSequence.countDown(); @@ -369,7 +370,7 @@ SELECT report_id, desired_sequence, JSON_UNQUOTE(JSON_EXTRACT(desired_snapshot, List> pending = reportRows(); List> changesBefore = changeRows(); - List> targetsBefore = targetRows(); + List> tasksBefore = taskRows(); long sequenceBefore = lastSequence(); Dining dining = diningRepository.getById(diningId); assertThat(dining.getSoldOut()).isNull(); @@ -390,7 +391,7 @@ SELECT report_id, desired_sequence, JSON_UNQUOTE(JSON_EXTRACT(desired_snapshot, String.class, diningId)).isNull(); assertThat(reportRows()).isEqualTo(pending); assertThat(changeRows()).isEqualTo(changesBefore); - assertThat(targetRows()).isEqualTo(targetsBefore); + assertThat(taskRows()).isEqualTo(tasksBefore); assertThat(lastSequence()).isEqualTo(sequenceBefore); assertThat(diningSoldOutCacheRepository.findById(dining.getPlace())).isEmpty(); assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification", Integer.class)).isZero(); @@ -409,8 +410,11 @@ SELECT report_id, desired_sequence, JSON_UNQUOTE(JSON_EXTRACT(desired_snapshot, .containsOnly(rejected.get(0).get("processing_id")); assertThat(changeRows()).hasSize(changesBefore.size() + 2); assertThat(lastSequence()).isEqualTo(sequenceBefore + 2); - assertThat(targetRows()).extracting(row -> ((Number)row.get("desired_sequence")).longValue()) + assertThat(taskRows()).filteredOn(row -> "PROCESSED".equals(row.get("event_type"))) + .extracting(row -> ((Number)row.get("sequence")).longValue()) .containsExactly(sequenceBefore + 1, sequenceBefore + 2); + assertThat(taskRows()).filteredOn(row -> "CREATED".equals(row.get("event_type"))) + .isEqualTo(tasksBefore); verify(coopEventListener).onDiningSoldOutRequest( new DiningSoldOutEvent(diningId, dining.getPlace(), dining.getType())); verify(fcmClient).sendMessages(argThat(requests -> requests.size() == 1 @@ -473,12 +477,13 @@ private long lastSequence() { "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class); } - private List> targetRows() { + private List> taskRows() { return jdbcTemplate.queryForList(""" - SELECT t.report_id, t.desired_sequence, t.desired_snapshot - FROM dining_soldout_report_delivery_target t - JOIN dining_soldout_report r ON r.id = t.report_id - WHERE r.dining_id = ? ORDER BY t.report_id + SELECT c.report_id, c.sequence, c.event_type, HEX(c.delivery_id) AS delivery_id, + c.report_snapshot, c.delivery_state + FROM dining_soldout_report_change c + JOIN dining_soldout_report r ON r.id = c.report_id + WHERE r.dining_id = ? ORDER BY c.sequence """, diningId); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java b/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java index a4aada64e..44d73f474 100644 --- a/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java @@ -8,11 +8,15 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.time.LocalDateTime; import org.flywaydb.core.Flyway; +import org.hibernate.Session; import org.hibernate.SessionFactory; import org.hibernate.cfg.Configuration; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -20,10 +24,8 @@ import in.koreatech.koin.domain.dining.model.Dining; import in.koreatech.koin.domain.dining.model.DiningReport; import in.koreatech.koin.domain.dining.model.DiningReportChange; -import in.koreatech.koin.domain.dining.model.DiningReportDelivery; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryAttempt; -import in.koreatech.koin.domain.dining.model.DiningReportDeliveryTarget; import in.koreatech.koin.domain.dining.model.DiningReportSequence; +import in.koreatech.koin.domain.dining.repository.DiningReportChangeRepository; @Testcontainers class DiningSoldOutReportMigrationTest { @@ -35,7 +37,7 @@ class DiningSoldOutReportMigrationTest { .withPassword("test"); @Test - void V11부터_V13까지_기존_이력을_보존하고_전송_스키마와_제약을_검증한다() throws SQLException { + void V14는_확인된_이력을_보존하고_최신_미완료_작업만_이관하며_구버전_추가를_배정한다() throws Exception { migrateTo("11"); try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { statement.executeUpdate(""" @@ -49,7 +51,6 @@ INSERT INTO dining_menus (id, date, type, place, menu, sold_out) """); } migrateTo("12"); - // V13 이전 제보는 메시지 연결을 알 수 없으므로 자동 CREATE 대상으로 이관하지 않는다. try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { statement.executeUpdate(""" INSERT INTO dining_soldout_report @@ -66,110 +67,187 @@ INSERT INTO dining_menus (id, date, type, place, menu, sold_out) VALUES (1, 301, 'PROCESSED', 'APPROVED', 'MANUAL', UNHEX(REPEAT('02', 16)), '2026-10-02 12:40:00') """); - statement.executeUpdate("UPDATE dining_soldout_report_sequence SET last_sequence = 1 WHERE id = 1"); + insertPendingReport(statement, 302, 2); + statement.executeUpdate("UPDATE dining_soldout_report_sequence SET last_sequence = 2 WHERE id = 1"); } migrateTo("13"); - // Flyway 결과를 실제 엔티티의 enum, UUID, sequence 매핑과 대조한다. - try (SessionFactory ignored = new Configuration() - .addAnnotatedClass(Dining.class) - .addAnnotatedClass(DiningReport.class) - .addAnnotatedClass(DiningReportChange.class) - .addAnnotatedClass(DiningReportSequence.class) - .addAnnotatedClass(DiningReportDeliveryTarget.class) - .addAnnotatedClass(DiningReportDelivery.class) - .addAnnotatedClass(DiningReportDeliveryAttempt.class) - .setProperty("hibernate.connection.url", MYSQL.getJdbcUrl()) - .setProperty("hibernate.connection.username", MYSQL.getUsername()) - .setProperty("hibernate.connection.password", MYSQL.getPassword()) - .setProperty("hibernate.hbm2ddl.auto", "validate") - .buildSessionFactory(); - Connection connection = getConnection(); - Statement statement = connection.createStatement()) { - assertThat(queryInt(statement, """ - SELECT COUNT(*) FROM dining_menus WHERE id = 201 AND menu = '기존 메뉴' - AND sold_out = '2026-10-02 12:30:00' AND sold_out_source IS NULL - """)).isOne(); - assertThat(queryInt(statement, """ - SELECT COUNT(*) FROM users u JOIN students s ON s.user_id = u.id - WHERE u.id = 101 AND u.anonymous_nickname = '기존학생' AND s.student_number = '2025100101' - """)).isOne(); - assertThat(queryInt(statement, - """ - SELECT COUNT(*) FROM dining_soldout_report_sequence - WHERE id = 1 AND last_sequence = 1 AND delivery_cooldown_until IS NULL - """)).isOne(); + try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { assertThat(queryInt(statement, "SELECT COUNT(*) FROM dining_soldout_report_delivery_target")).isZero(); - assertThat(queryInt(statement, "SELECT COUNT(*) FROM dining_soldout_report_delivery")).isZero(); - assertThat(queryInt(statement, "SELECT COUNT(*) FROM dining_soldout_report_delivery_attempt")).isZero(); - + insertPendingReport(statement, 303, 3); + insertLegacyTarget(statement, 303, 3, 0, "CONFLICT"); + insertLegacyDelivery(statement, 303, 3, "03", false); statement.executeUpdate(""" - INSERT INTO dining_soldout_report - (id, dining_id, image_url, request_key, created_at, updated_at) - VALUES (302, 201, 'https://example.com/new.jpg', UNHEX(REPEAT('03', 16)), - '2026-10-02 12:45:00', '2026-10-02 12:45:00') + INSERT INTO dining_soldout_report_delivery_attempt + (token, delivery_id, mode, send_attempt_token, issued_at, expires_at, evidence_history) + VALUES (UNHEX(REPEAT('06', 16)), UNHEX(REPEAT('03', 16)), 'SEND', UNHEX(REPEAT('06', 16)), + '2026-10-02 12:45:00.123456', '2026-10-02 12:46:00.123456', '[]') """); statement.executeUpdate(""" - INSERT INTO dining_soldout_report_change - (sequence, report_id, event_type, status, occurred_at) - VALUES (2, 302, 'CREATED', 'PENDING', '2026-10-02 12:45:00') + UPDATE dining_soldout_report + SET status = 'APPROVED', processing_type = 'MANUAL', processing_id = UNHEX(REPEAT('07', 16)), + processed_at = '2026-10-02 12:50:00', updated_at = '2026-10-02 12:50:00', + processor_workspace_id = 'workspace', processor_user_id = 'processor', processor_name = '담당자' + WHERE id = 303 """); - statement.executeUpdate("UPDATE dining_soldout_report_sequence SET last_sequence = 2 WHERE id = 1"); statement.executeUpdate(""" - INSERT INTO dining_soldout_report_delivery_target - (report_id, desired_sequence, desired_snapshot, workspace_id, channel_id, created_at, updated_at) - VALUES (302, 2, '{"report_id":302,"status":"PENDING"}', 'T_EXAMPLE', 'C_EXAMPLE', - '2026-10-02 12:45:00.123456', '2026-10-02 12:45:00.123456') + INSERT INTO dining_soldout_report_change + (sequence, report_id, event_type, status, processing_type, processing_id, occurred_at) + VALUES (4, 303, 'PROCESSED', 'APPROVED', 'MANUAL', UNHEX(REPEAT('07', 16)), '2026-10-02 12:50:00') """); statement.executeUpdate(""" - INSERT INTO dining_soldout_report_delivery - (id, report_id, source_sequence, report_snapshot, operation, workspace_id, channel_id, - status, next_attempt_at, created_at, updated_at) - VALUES (UNHEX(REPEAT('04', 16)), 302, 2, '{"report_id":302,"status":"PENDING"}', - 'CREATE', 'T_EXAMPLE', 'C_EXAMPLE', 'QUEUED', '2026-10-02 12:45:00.123456', - '2026-10-02 12:45:00.123456', '2026-10-02 12:45:00.123456') - """); - statement.executeUpdate(""" - INSERT INTO dining_soldout_report_delivery_attempt - (token, delivery_id, mode, send_attempt_token, issued_at, expires_at, evidence_history) - VALUES (UNHEX(REPEAT('05', 16)), UNHEX(REPEAT('04', 16)), 'SEND', UNHEX(REPEAT('05', 16)), - '2026-10-02 12:45:00.123456', '2026-10-02 12:46:00.123456', '[]') + UPDATE dining_soldout_report_delivery_target + SET desired_sequence = 4, desired_snapshot = '{"report_id":303,"status":"APPROVED"}' + WHERE report_id = 303 """); + insertPendingReport(statement, 304, 5); + insertLegacyTarget(statement, 304, 5, 0, "LEGACY"); + insertLegacyDelivery(statement, 304, 5, "04", false); + insertPendingReport(statement, 305, 6); + insertLegacyTarget(statement, 305, 6, 6, null); + insertLegacyDelivery(statement, 305, 6, "05", true); + statement.executeUpdate("UPDATE dining_soldout_report_sequence SET last_sequence = 6 WHERE id = 1"); + } + migrateTo("14"); + try (SessionFactory sessionFactory = validateSchema(); + Connection connection = getConnection(); Statement statement = connection.createStatement()) { + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_menus WHERE id = 201 AND menu = '기존 메뉴' + AND sold_out = '2026-10-02 12:30:00' AND sold_out_source IS NULL + """)).isOne(); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM users u JOIN students s ON s.user_id = u.id + WHERE u.id = 101 AND u.anonymous_nickname = '기존학생' AND s.student_number = '2025100101' + """)).isOne(); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_sequence + WHERE id = 1 AND last_sequence = 6 AND delivery_cooldown_until IS NULL + """)).isOne(); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_change + WHERE sequence IN (1, 2, 3, 6) AND delivery_state = 'DELIVERED' + """)).isEqualTo(4); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_change + WHERE sequence IN (1, 2) AND delivery_id IS NULL AND report_snapshot IS NULL + AND attempt_token IS NULL AND accepted_outcome IS NULL + """)).isEqualTo(2); + assertThat(queryInt(statement, "SELECT COUNT(*) FROM dining_soldout_report_change WHERE delivery_state = 'QUEUED'")) + .isEqualTo(2); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_change WHERE sequence = 4 AND delivery_id IS NULL + AND delivery_state = 'QUEUED' AND JSON_UNQUOTE(JSON_EXTRACT(report_snapshot, '$.status')) = 'APPROVED' + """)).isOne(); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_change c + JOIN dining_soldout_report_delivery d ON d.report_id = c.report_id AND d.source_sequence = c.sequence + WHERE c.sequence IN (3, 5, 6) AND c.delivery_id = d.id AND c.report_snapshot = d.report_snapshot + """)).isEqualTo(3); + assertThat(queryInt(statement, "SELECT COUNT(*) FROM dining_soldout_report_delivery_target")).isEqualTo(3); + assertThat(queryInt(statement, "SELECT COUNT(*) FROM dining_soldout_report_delivery")).isEqualTo(3); assertThat(queryInt(statement, """ SELECT COUNT(*) FROM dining_soldout_report_delivery_attempt - WHERE expires_at = DATE_ADD(issued_at, INTERVAL 60 SECOND) AND MICROSECOND(issued_at) = 123456 + WHERE token = UNHEX(REPEAT('06', 16)) AND mode = 'SEND' + AND MICROSECOND(issued_at) = 123456 AND evidence_history = '[]' """)).isOne(); assertThatThrownBy(() -> statement.executeUpdate(""" - INSERT INTO dining_soldout_report_delivery - (id, report_id, source_sequence, report_snapshot, operation, workspace_id, channel_id, - status, created_at, updated_at) - SELECT UNHEX(REPEAT('06', 16)), report_id, source_sequence, report_snapshot, operation, - workspace_id, channel_id, status, created_at, updated_at - FROM dining_soldout_report_delivery - """)).isInstanceOf(SQLException.class).hasMessageContaining("uk_dining_delivery_snapshot"); + UPDATE dining_soldout_report_change SET delivery_id = UNHEX(REPEAT('04', 16)) WHERE sequence = 4 + """)).isInstanceOf(SQLException.class).hasMessageContaining("uk_dining_report_change_delivery"); assertThatThrownBy(() -> statement.executeUpdate(""" - UPDATE dining_soldout_report_delivery_attempt SET expires_at = DATE_ADD(expires_at, INTERVAL 1 SECOND) - """)).isInstanceOf(SQLException.class).hasMessageContaining("chk_dining_delivery_attempt_deadline"); + UPDATE dining_soldout_report_change SET report_snapshot = 'invalid-json' WHERE sequence = 4 + """)).isInstanceOf(SQLException.class).hasMessageContaining("chk_dining_report_change_snapshot"); assertThatThrownBy(() -> statement.executeUpdate(""" - UPDATE dining_soldout_report_delivery_attempt - SET mode = 'VERIFY', accepted_outcome = 'NOT_APPLIED', accepted_result = '{}', - result_at = '2026-10-02 12:45:30' - """)).isInstanceOf(SQLException.class).hasMessageContaining("chk_dining_delivery_attempt_verify"); - statement.executeUpdate("DELETE FROM users WHERE id = 101"); + UPDATE dining_soldout_report_change SET delivery_state = 'UNCERTAIN' WHERE sequence = 4 + """)).isInstanceOf(SQLException.class).hasMessageContaining("chk_dining_report_change_delivery_state"); + assertThatThrownBy(() -> statement.executeUpdate(""" + UPDATE dining_soldout_report_change SET delivery_state = 'IN_PROGRESS' WHERE sequence = 4 + """)).isInstanceOf(SQLException.class).hasMessageContaining("chk_dining_report_change_lease"); + assertThatThrownBy(() -> statement.executeUpdate(""" + UPDATE dining_soldout_report_change SET accepted_outcome = 'NOT_APPLIED' WHERE sequence = 4 + """)).isInstanceOf(SQLException.class).hasMessageContaining("chk_dining_report_change_outcome"); + statement.executeUpdate(""" + UPDATE dining_soldout_report_change SET attempt_token = UNHEX(REPEAT('08', 16)) WHERE sequence = 5 + """); + assertThatThrownBy(() -> statement.executeUpdate(""" + UPDATE dining_soldout_report_change SET attempt_token = UNHEX(REPEAT('08', 16)) WHERE sequence = 4 + """)).isInstanceOf(SQLException.class).hasMessageContaining("uk_dining_report_change_attempt"); - assertThat(queryInt(statement, "SELECT COUNT(*) FROM users WHERE id = 101")).isZero(); + // A late old application uses only V12 columns and still produces claimable work after V14. + insertPendingReport(statement, 306, 7); + statement.executeUpdate("UPDATE dining_soldout_report_sequence SET last_sequence = 7 WHERE id = 1"); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_change WHERE sequence = 7 AND delivery_state = 'QUEUED' + AND delivery_id IS NULL AND report_snapshot IS NULL AND attempt_token IS NULL + AND expires_at IS NULL AND next_attempt_at IS NULL AND accepted_outcome IS NULL + """)).isOne(); + String claimQuery = DiningReportChangeRepository.class + .getMethod("findClaimable", LocalDateTime.class, Pageable.class).getAnnotation(Query.class).value(); + try (Session session = sessionFactory.openSession()) { + assertThat(session.createQuery(claimQuery, DiningReportChange.class) + .setParameter("now", LocalDateTime.of(2026, 10, 2, 13, 0)).getResultList()) + .extracting(DiningReportChange::getSequence).containsExactly(4L, 5L, 7L); + } + statement.executeUpdate("DELETE FROM users WHERE id = 101"); assertThat(queryInt(statement, """ SELECT COUNT(*) FROM dining_soldout_report WHERE id = 301 AND reporter_id IS NULL - AND dining_id = 201 AND status = 'APPROVED' AND processor_name = '담당자' - AND processing_type = 'MANUAL' AND processing_id = UNHEX(REPEAT('02', 16)) + AND status = 'APPROVED' AND processor_name = '담당자' AND processing_type = 'MANUAL' + AND processing_id = UNHEX(REPEAT('02', 16)) """)).isOne(); assertThat(queryInt(statement, """ - SELECT COUNT(*) FROM dining_soldout_report_change WHERE report_id = 301 AND sequence = 1 + SELECT COUNT(*) FROM dining_soldout_report_change WHERE sequence = 1 AND report_id = 301 AND event_type = 'PROCESSED' AND status = 'APPROVED' AND processing_type = 'MANUAL' - AND processing_id = UNHEX(REPEAT('02', 16)) + AND processing_id = UNHEX(REPEAT('02', 16)) AND delivery_state = 'DELIVERED' """)).isOne(); } } + private static void insertPendingReport(Statement statement, int reportId, long sequence) throws SQLException { + statement.executeUpdate(""" + INSERT INTO dining_soldout_report (id, dining_id, image_url, request_key, created_at, updated_at) + VALUES (%d, 201, 'https://example.com/new.jpg', UNHEX(REPEAT('01', 16)), + '2026-10-02 12:45:00', '2026-10-02 12:45:00') + """.formatted(reportId)); + statement.executeUpdate(""" + INSERT INTO dining_soldout_report_change (sequence, report_id, event_type, status, occurred_at) + VALUES (%d, %d, 'CREATED', 'PENDING', '2026-10-02 12:45:00') + """.formatted(sequence, reportId)); + } + + private static void insertLegacyTarget(Statement statement, int reportId, long sequence, long confirmed, + String hold) throws SQLException { + statement.executeUpdate(""" + INSERT INTO dining_soldout_report_delivery_target + (report_id, desired_sequence, desired_snapshot, confirmed_sequence, hold_reason, created_at, updated_at) + VALUES (%d, %d, '{"report_id":%d,"status":"PENDING"}', %d, %s, + '2026-10-02 12:45:00.123456', '2026-10-02 12:45:00.123456') + """.formatted(reportId, sequence, reportId, confirmed, hold == null ? "NULL" : "'" + hold + "'")); + } + + private static void insertLegacyDelivery(Statement statement, int reportId, long sequence, String id, + boolean delivered) throws SQLException { + statement.executeUpdate(""" + INSERT INTO dining_soldout_report_delivery + (id, report_id, source_sequence, report_snapshot, operation, workspace_id, channel_id, + status, confirmed_message_ts, created_at, updated_at) + VALUES (UNHEX(REPEAT('%s', 16)), %d, %d, '{"report_id":%d,"status":"PENDING"}', + 'CREATE', 'T_EXAMPLE', 'C_EXAMPLE', '%s', %s, + '2026-10-02 12:45:00.123456', '2026-10-02 12:45:00.123456') + """.formatted(id, reportId, sequence, reportId, delivered ? "DELIVERED" : "QUEUED", + delivered ? "'123.000'" : "NULL")); + } + + private static SessionFactory validateSchema() { + return new Configuration() + .addAnnotatedClass(Dining.class) + .addAnnotatedClass(DiningReport.class) + .addAnnotatedClass(DiningReportChange.class) + .addAnnotatedClass(DiningReportSequence.class) + .setProperty("hibernate.connection.url", MYSQL.getJdbcUrl()) + .setProperty("hibernate.connection.username", MYSQL.getUsername()) + .setProperty("hibernate.connection.password", MYSQL.getPassword()) + .setProperty("hibernate.hbm2ddl.auto", "validate") + .buildSessionFactory(); + } + private static void migrateTo(String version) { Flyway.configure() .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) From 4fe34d04caded2b062320597bd241fea6966fcb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:56:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EC=82=90=EB=B4=87=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/DiningReportDeliveryApiTest.java | 620 ------------------ .../DiningReportDeliveryServiceTest.java | 299 +++++++++ .../domain/DiningSoldOutReportApiTest.java | 99 --- 3 files changed, 299 insertions(+), 719 deletions(-) delete mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java deleted file mode 100644 index 4249b1182..000000000 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java +++ /dev/null @@ -1,620 +0,0 @@ -package in.koreatech.koin.acceptance.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mockingDetails; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.when; -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.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.time.OffsetDateTime; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.http.MediaType; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import in.koreatech.koin.acceptance.AcceptanceTest; -import in.koreatech.koin.acceptance.fixture.CoopShopAcceptanceFixture; -import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; -import in.koreatech.koin.acceptance.fixture.DiningAcceptanceFixture; -import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; -import in.koreatech.koin.domain.dining.repository.DiningReportSequenceRepository; -import in.koreatech.koin.infrastructure.s3.client.S3Client; - -@Transactional(propagation = Propagation.NOT_SUPPORTED) -@TestPropertySource(properties = "dining.report.bot-token=test-dining-report-bot-token") -class DiningReportDeliveryApiTest extends AcceptanceTest { - - private static final String BOT_PATH = "/internal/dining/soldout-reports"; - private static final String BOT_HEADER = "X-Koin-Service-Token"; - private static final String BOT_TOKEN = "test-dining-report-bot-token"; - private static final String IMAGE_DOMAIN = "https://test.koreatech.in/"; - private static final String IMAGE_URL = IMAGE_DOMAIN - + "upload/COOP/2024/1/15/e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg"; - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final Instant START = Instant.parse("2024-01-15T03:00:00Z"); - private static final long TIMEOUT_SECONDS = 10; - - @Autowired - private UserAcceptanceFixture userFixture; - - @Autowired - private DepartmentAcceptanceFixture departmentFixture; - - @Autowired - private DiningAcceptanceFixture diningFixture; - - @Autowired - private CoopShopAcceptanceFixture coopShopFixture; - - @Autowired - private JdbcTemplate jdbcTemplate; - - @Autowired - private PlatformTransactionManager transactionManager; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private S3Client s3Client; - - @MockBean - private Clock deliveryClock; - - @SpyBean - private DiningReportSequenceRepository sequenceRepository; - - private final AtomicReference now = new AtomicReference<>(START); - private String studentToken; - private Integer diningId; - private Integer otherDiningId; - - @BeforeEach - void setUp() { - now.set(START); - when(deliveryClock.instant()).thenAnswer(invocation -> now.get()); - when(deliveryClock.getZone()).thenReturn(KST); - when(deliveryClock.withZone(any(ZoneId.class))) - .thenAnswer(invocation -> Clock.fixed(now.get(), invocation.getArgument(0))); - clear(); - new TransactionTemplate(transactionManager).executeWithoutResult(status -> { - jdbcTemplate.update("INSERT INTO dining_soldout_report_sequence (id, last_sequence) VALUES (1, 0)"); - coopShopFixture.현재학기(); - var student = userFixture.준호_학생(departmentFixture.컴퓨터공학부(), null).getUser(); - studentToken = userFixture.getToken(student); - LocalDate today = now.get().atZone(KST).toLocalDate(); - diningId = diningFixture.A코너_점심(today).getId(); - otherDiningId = diningFixture.B코너_점심(today).getId(); - }); - when(s3Client.getDomainUrlPrefix()).thenReturn(IMAGE_DOMAIN); - when(s3Client.isCustomDomainUrl(anyString())) - .thenAnswer(invocation -> invocation.getArgument(0).startsWith(IMAGE_DOMAIN)); - when(s3Client.extractKeyFromUrl(anyString())) - .thenAnswer(invocation -> invocation.getArgument(0).substring(IMAGE_DOMAIN.length())); - when(s3Client.doesFileExist(anyString())).thenReturn(true); - } - - @AfterEach - void tearDown() { - clear(); - } - - @Test - void 봇_토큰만으로_작업을_배정하고_두_필드_성공_결과로_완료한다() throws Exception { - int reportId = submit(diningId); - JsonNode task = nextClaim(); - assertThat(task.fieldNames()).toIterable() - .containsExactlyInAnyOrder("delivery_id", "attempt_token", "expires_at", "report"); - assertThat(task.path("report").path("report_id").asInt()).isEqualTo(reportId); - assertThat(OffsetDateTime.parse(task.path("expires_at").asText())) - .isEqualTo(START.atZone(KST).toOffsetDateTime().plusSeconds(60)); - postResult(task.path("delivery_id").asText(), objectMapper.writeValueAsString(Map.of( - "attempt_token", task.path("attempt_token").asText(), "outcome", "SUCCEEDED"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_id").value(task.path("delivery_id").asText())) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); - emptyClaim(); - } - - @Test - void 서비스_토큰으로만_배정과_결과를_인증하고_빈_작업은_204를_반환한다() throws Exception { - mockMvc.perform(post(BOT_PATH + "/deliveries/claim")).andExpect(status().isUnauthorized()); - mockMvc.perform(post(BOT_PATH + "/deliveries/claim").header(BOT_HEADER, "wrong-token")) - .andExpect(status().isUnauthorized()); - mockMvc.perform(post(BOT_PATH + "/deliveries/claim").header("Authorization", "Bearer " + studentToken)) - .andExpect(status().isUnauthorized()); - emptyClaim(); - submit(diningId); - JsonNode task = nextClaim(); - emptyClaim(); - mockMvc.perform(post(BOT_PATH + "/deliveries/{id}/result", task.path("delivery_id").asText()) - .contentType(MediaType.APPLICATION_JSON).content(resultBody(task, "SUCCEEDED"))) - .andExpect(status().isUnauthorized()); - result(task, "SUCCEEDED").andExpect(status().isOk()); - } - - @Test - void 성공_재통보는_멱등이며_제보_업무와_후속_변경을_완료하지_않는다() throws Exception { - int reportId = submit(diningId); - JsonNode task = nextClaim(); - JsonNode completed = body(result(task, "SUCCEEDED").andExpect(status().isOk())); - assertThat(completed.fieldNames()).toIterable().containsExactlyInAnyOrder("delivery_id", "delivery_state"); - assertThat(completed.path("delivery_state").asText()).isEqualTo("DELIVERED"); - assertThat(body(result(task, "SUCCEEDED").andExpect(status().isOk()))).isEqualTo(completed); - assertThat(reportStatus(reportId)).isEqualTo("PENDING"); - assertThat(jdbcTemplate.queryForObject("SELECT sold_out FROM dining_menus WHERE id = ?", - Object.class, diningId)).isNull(); - result(task, "FAILED").andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); - emptyClaim(); - JsonNode approved = approve(reportId).path("report"); - JsonNode next = nextClaim(); - assertThat(next.path("delivery_id")).isNotEqualTo(task.path("delivery_id")); - assertThat(next.path("report")).isEqualTo(approved); - result(task, "SUCCEEDED").andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); - emptyClaim(); - result(next, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @Test - void 최초_배정_전에_승인해도_생성과_처리_스냅샷을_순서대로_배정한다() throws Exception { - int reportId = submit(diningId); - JsonNode approved = approve(reportId).path("report"); - JsonNode created = nextClaim(); - assertThat(created.path("report").path("status").asText()).isEqualTo("PENDING"); - assertThat(created.path("report").path("processor").isNull()).isTrue(); - emptyClaim(); - result(created, "SUCCEEDED").andExpect(status().isOk()); - JsonNode processed = nextClaim(); - assertThat(processed.path("delivery_id")).isNotEqualTo(created.path("delivery_id")); - assertThat(processed.path("report")).isEqualTo(approved); - result(processed, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @Test - void 실패는_5초_후_같은_작업을_재배정하고_재통보가_대기를_연장하지_않는다() throws Exception { - int reportId = submit(diningId); - JsonNode task = nextClaim(); - JsonNode approved = approve(reportId).path("report"); - result(task, "FAILED").andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("QUEUED")); - advance(4); - result(task, "FAILED").andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("QUEUED")); - result(task, "SUCCEEDED").andExpect(status().isConflict()); - emptyClaim(); - advance(1); - JsonNode retry = nextClaim(); - assertSameWork(task, retry); - result(task, "FAILED").andExpect(status().isConflict()); - result(task, "SUCCEEDED").andExpect(status().isConflict()); - result(retry, "SUCCEEDED").andExpect(status().isOk()); - JsonNode processed = nextClaim(); - assertThat(processed.path("report")).isEqualTo(approved); - result(processed, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @Test - void 한_제보의_실패_대기는_다른_제보의_배정을_막지_않는다() throws Exception { - int firstReportId = submit(diningId); - int secondReportId = submit(otherDiningId); - JsonNode first = nextClaim(); - assertThat(first.path("report").path("report_id").asInt()).isEqualTo(firstReportId); - result(first, "FAILED").andExpect(status().isOk()); - approve(firstReportId); - JsonNode second = nextClaim(); - assertThat(second.path("report").path("report_id").asInt()).isEqualTo(secondReportId); - result(second, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - advance(5); - assertSameWork(first, nextClaim()); - } - - @Test - void 구버전의_늦은_변경_INSERT는_첫_claim에서_UUID와_스냅샷을_초기화한다() throws Exception { - int existingReportId = submit(diningId); - result(nextClaim(), "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - int legacyReportId = new TransactionTemplate(transactionManager).execute(transaction -> { - jdbcTemplate.update(""" - INSERT INTO dining_soldout_report - (dining_id, reporter_id, image_url, status, request_key, created_at, updated_at) - SELECT ?, reporter_id, image_url, 'PENDING', UNHEX(REPLACE(?, '-', '')), created_at, updated_at - FROM dining_soldout_report WHERE id = ? - """, otherDiningId, UUID.randomUUID().toString(), existingReportId); - int reportId = jdbcTemplate.queryForObject( - "SELECT id FROM dining_soldout_report WHERE dining_id = ?", Integer.class, otherDiningId); - long sequence = sequenceRepository.findForUpdate().next(); - jdbcTemplate.update(""" - INSERT INTO dining_soldout_report_change - (sequence, report_id, event_type, status, processing_type, processing_id, occurred_at) - VALUES (?, ?, 'CREATED', 'PENDING', NULL, NULL, ?) - """, sequence, reportId, now.get().atZone(KST).toLocalDateTime()); - return reportId; - }); - assertThat(jdbcTemplate.queryForObject( - "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class)).isEqualTo(2L); - assertThat(jdbcTemplate.queryForMap(""" - SELECT delivery_id, report_snapshot, delivery_state, attempt_token, expires_at, next_attempt_at, accepted_outcome - FROM dining_soldout_report_change WHERE report_id = ? - """, legacyReportId)) - .containsEntry("delivery_id", null).containsEntry("report_snapshot", null) - .containsEntry("delivery_state", "QUEUED").containsEntry("attempt_token", null) - .containsEntry("expires_at", null).containsEntry("next_attempt_at", null).containsEntry("accepted_outcome", null); - JsonNode expectedReport = body(mockMvc.perform(get(BOT_PATH + "/{id}", legacyReportId) - .header(BOT_HEADER, BOT_TOKEN)).andExpect(status().isOk())); - JsonNode task = nextClaim(); - String deliveryId = task.path("delivery_id").asText(); - assertThat(UUID.fromString(deliveryId).toString()).isEqualTo(deliveryId); - assertThat(task.path("report")).isEqualTo(expectedReport); - String storedSnapshot = jdbcTemplate.queryForObject(""" - SELECT report_snapshot FROM dining_soldout_report_change - WHERE report_id = ? AND delivery_id = UNHEX(REPLACE(?, '-', '')) - """, String.class, legacyReportId, deliveryId); - assertThat(objectMapper.readTree(storedSnapshot)).isEqualTo(expectedReport); - result(task, "SUCCEEDED").andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); - emptyClaim(); - JsonNode approved = approve(legacyReportId).path("report"); - assertThat(jdbcTemplate.queryForObject( - "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class)).isEqualTo(3L); - JsonNode processed = nextClaim(); - assertThat(processed.path("report")).isEqualTo(approved); - result(processed, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @Test - void 배정_60초_만료_즉시_새_토큰을_배정하고_이전_토큰의_결과는_거부한다() throws Exception { - int reportId = submit(diningId); - JsonNode first = nextClaim(); - JsonNode approved = approve(reportId).path("report"); - advance(59); - emptyClaim(); - advance(1); - JsonNode replacement = nextClaim(); - assertSameWork(first, replacement); - assertThat(OffsetDateTime.parse(replacement.path("expires_at").asText())) - .isEqualTo(START.atZone(KST).toOffsetDateTime().plusSeconds(120)); - for (String outcome : List.of("SUCCEEDED", "FAILED")) { - result(first, outcome).andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); - } - emptyClaim(); - result(replacement, "SUCCEEDED").andExpect(status().isOk()); - JsonNode processed = nextClaim(); - assertThat(processed.path("report")).isEqualTo(approved); - result(processed, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @ParameterizedTest - @ValueSource(strings = {"SUCCEEDED", "FAILED"}) - void 만료되어도_재배정되지_않은_현재_토큰의_늦은_결과를_접수한다(String outcome) throws Exception { - submit(diningId); - JsonNode task = nextClaim(); - advance(61); - String expectedState = outcome.equals("SUCCEEDED") ? "DELIVERED" : "QUEUED"; - result(task, outcome).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value(expectedState)); - result(task, outcome).andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value(expectedState)); - result(task, outcome.equals("SUCCEEDED") ? "FAILED" : "SUCCEEDED").andExpect(status().isConflict()); - emptyClaim(); - if (outcome.equals("FAILED")) { - advance(5); - JsonNode retry = nextClaim(); - assertSameWork(task, retry); - result(retry, "SUCCEEDED").andExpect(status().isOk()); - } - emptyClaim(); - } - - @Test - void 없는_작업과_다른_작업의_토큰은_404이고_알_수_없는_현재_토큰은_409이다() throws Exception { - submit(diningId); - submit(otherDiningId); - JsonNode first = nextClaim(); - JsonNode second = nextClaim(); - postResult(UUID.randomUUID().toString(), resultBody(first, "SUCCEEDED")) - .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_FOUND_DINING_REPORT_DELIVERY")); - postResult(first.path("delivery_id").asText(), resultBody(second, "SUCCEEDED")) - .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_FOUND_DINING_REPORT_DELIVERY")); - postResult(first.path("delivery_id").asText(), objectMapper.writeValueAsString(Map.of( - "attempt_token", UUID.randomUUID().toString(), "outcome", "SUCCEEDED"))) - .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("DINING_REPORT_DELIVERY_CONFLICT")); - result(first, "SUCCEEDED").andExpect(status().isOk()); - result(second, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @ParameterizedTest - @ValueSource(strings = {"message_ref", "reason", "error_code", "retry_after_seconds", "mode", "operation", "target", "unexpected"}) - void 결과의_미지원_필드는_null이어도_거부한다(String field) throws Exception { - submit(diningId); - JsonNode task = nextClaim(); - for (String outcome : List.of("SUCCEEDED", "FAILED")) { - for (Object value : new Object[] {null, true}) { - Map request = new LinkedHashMap<>(); - request.put("attempt_token", task.path("attempt_token").asText()); - request.put("outcome", outcome); - request.put(field, value); - postResult(task.path("delivery_id").asText(), objectMapper.writeValueAsString(request)) - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); - } - } - result(task, "SUCCEEDED").andExpect(status().isOk()); - } - - @Test - void 결과는_필수_문자열과_두_결과값과_정규_UUID를_검증한다() throws Exception { - submit(diningId); - JsonNode task = nextClaim(); - String deliveryId = task.path("delivery_id").asText(); - String token = task.path("attempt_token").asText(); - Map> invalidBodies = Map.of( - "INVALID_REQUEST_BODY", List.of( - "{}", "{\"outcome\":\"SUCCEEDED\"}", - "{\"attempt_token\":null,\"outcome\":\"SUCCEEDED\"}", - "{\"attempt_token\":\"\",\"outcome\":\"SUCCEEDED\"}", - "{\"attempt_token\":\"%s\"}".formatted(token), - "{\"attempt_token\":\"%s\",\"outcome\":null}".formatted(token), - "{\"attempt_token\":\"%s\",\"outcome\":\"\"}".formatted(token)), - "NOT_READABLE_HTTP_MESSAGE", List.of( - "null", "[]", "true", "{\"attempt_token\":123,\"outcome\":\"SUCCEEDED\"}", - "{\"attempt_token\":\"not-a-uuid\",\"outcome\":\"SUCCEEDED\"}", - "{\"attempt_token\":\"1-1-1-1-1\",\"outcome\":\"SUCCEEDED\"}", - "{\"attempt_token\":\"%s\",\"outcome\":true}".formatted(token), - "{\"attempt_token\":\"%s\",\"outcome\":\"UNCERTAIN\"}".formatted(token), - "{\"attempt_token\":\"%s\",\"outcome\":\"NOT_APPLIED\"}".formatted(token), - "{\"attempt_token\":\"%s\",\"outcome\":\"succeeded\"}".formatted(token))); - for (var invalid : invalidBodies.entrySet()) { - for (String request : invalid.getValue()) { - postResult(deliveryId, request).andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(invalid.getKey())); - } - } - for (String invalidId : List.of("not-a-uuid", "1-1-1-1-1")) { - JsonNode error = body(postResult(invalidId, resultBody(task, "SUCCEEDED")) - .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("ILLEGAL_ARGUMENT")) - .andExpect(jsonPath("$.status").doesNotHaveJsonPath())); - assertThat(error.path("message").asText()).isNotBlank(); - assertThat(UUID.fromString(error.path("errorTraceId").asText()).toString()) - .isEqualTo(error.path("errorTraceId").asText()); - } - result(task, "SUCCEEDED").andExpect(status().isOk()); - } - - @Test - void 동시_claim은_하나의_작업에_하나의_시도만_배정한다() throws Exception { - int reportId = submit(diningId); - List responses = concurrentlyAtGate(() -> claim().andReturn(), () -> claim().andReturn()); - assertThat(responses).extracting(response -> response.getResponse().getStatus()) - .containsExactlyInAnyOrder(200, 204); - JsonNode task = body(responses.stream().filter(response -> response.getResponse().getStatus() == 200) - .findFirst().orElseThrow()); - assertThat(jdbcTemplate.queryForObject(""" - SELECT COUNT(*) FROM dining_soldout_report_change - WHERE report_id = ? AND delivery_state = 'IN_PROGRESS' - AND delivery_id = UNHEX(REPLACE(?, '-', '')) AND attempt_token = UNHEX(REPLACE(?, '-', '')) - """, Integer.class, reportId, task.path("delivery_id").asText(), task.path("attempt_token").asText())).isOne(); - emptyClaim(); - result(task, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void 동시_결과는_같으면_멱등이고_다르면_409이며_후속_작업을_보류하지_않는다(boolean conflicting) throws Exception { - int reportId = submit(diningId); - JsonNode task = nextClaim(); - List responses = concurrentlyAtGate( - () -> result(task, "SUCCEEDED").andReturn(), - () -> result(task, conflicting ? "FAILED" : "SUCCEEDED").andReturn()); - assertThat(responses).extracting(response -> response.getResponse().getStatus()) - .containsExactlyInAnyOrder(200, conflicting ? 409 : 200); - result(task, "SUCCEEDED").andExpect(status().isOk()) - .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); - assertThat(reportStatus(reportId)).isEqualTo("PENDING"); - JsonNode approved = approve(reportId).path("report"); - JsonNode processed = nextClaim(); - assertThat(processed.path("report")).isEqualTo(approved); - result(processed, "SUCCEEDED").andExpect(status().isOk()); - emptyClaim(); - } - - @ParameterizedTest - @ValueSource(strings = {"/v3/api-docs", "/v3/api-docs/3. Campus API"}) - void 기본과_Campus_스키마는_단순한_닫힌_작업_객체와_해결된_참조를_제공한다(String path) throws Exception { - JsonNode document = body(mockMvc.perform(get(path)).andExpect(status().isOk())); - JsonNode schemas = document.path("components").path("schemas"); - assertClosedSchema(schemas.path("DiningReportDeliveryResponse"), - "delivery_id", "attempt_token", "expires_at", "report"); - assertClosedSchema(schemas.path("DiningReportDeliveryResultRequest"), "attempt_token", "outcome"); - assertClosedSchema(schemas.path("DiningReportDeliveryResultResponse"), "delivery_id", "delivery_state"); - assertThat(schemas.path("DiningReportDeliveryResultRequest").path("properties").path("outcome").path("enum")) - .isEqualTo(objectMapper.valueToTree(List.of("SUCCEEDED", "FAILED"))); - assertThat(schemas.path("DiningReportDeliveryResultResponse").path("properties").path("delivery_state").path("enum")) - .isEqualTo(objectMapper.valueToTree(List.of("QUEUED", "IN_PROGRESS", "DELIVERED"))); - assertThat(schemas.fieldNames()).toIterable().doesNotContain("DiningReportDeliveryMessageTarget", - "DiningReportDeliveryMessageReference", "DiningReportDeliverySucceeded", "DiningReportDeliveryNotSent", - "DiningReportDeliveryRateLimited", "DiningReportDeliveryRejected", "DiningReportDeliveryUncertain"); - List references = new ArrayList<>(); - collectReferences(document, references); - assertThat(references.stream().filter(reference -> reference.startsWith("#/components/schemas/")) - .filter(reference -> document.at(reference.substring(1)).isMissingNode()).distinct().toList()).isEmpty(); - } - - private int submit(Integer targetDiningId) throws Exception { - return body(mockMvc.perform(post("/dinings/{id}/soldout-reports", targetDiningId) - .header("Authorization", "Bearer " + studentToken) - .header("Idempotency-Key", UUID.randomUUID().toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(Map.of("image_url", IMAGE_URL)))) - .andExpect(status().isCreated())).path("report_id").asInt(); - } - - private JsonNode approve(int reportId) throws Exception { - return body(mockMvc.perform(post(BOT_PATH + "/{id}/approve", reportId).header(BOT_HEADER, BOT_TOKEN) - .contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(Map.of("actor", - Map.of("workspace_id", "T_EXAMPLE", "user_id", "U_REVIEWER", "display_name", "담당자"))))) - .andExpect(status().isOk())); - } - - private ResultActions claim() throws Exception { - return mockMvc.perform(post(BOT_PATH + "/deliveries/claim").header(BOT_HEADER, BOT_TOKEN)); - } - - private JsonNode nextClaim() throws Exception { - return body(claim().andExpect(status().isOk())); - } - - private void emptyClaim() throws Exception { - claim().andExpect(status().isNoContent()).andExpect(header().string("Retry-After", "5")) - .andExpect(content().string("")); - } - - private ResultActions result(JsonNode task, String outcome) throws Exception { - return postResult(task.path("delivery_id").asText(), resultBody(task, outcome)); - } - - private String resultBody(JsonNode task, String outcome) throws Exception { - return objectMapper.writeValueAsString(Map.of( - "attempt_token", task.path("attempt_token").asText(), "outcome", outcome)); - } - - private ResultActions postResult(String deliveryId, String request) throws Exception { - return mockMvc.perform(post(BOT_PATH + "/deliveries/{id}/result", deliveryId).header(BOT_HEADER, BOT_TOKEN) - .contentType(MediaType.APPLICATION_JSON).content(request)); - } - - private void assertSameWork(JsonNode original, JsonNode next) { - assertThat(next.path("delivery_id")).isEqualTo(original.path("delivery_id")); - assertThat(next.path("attempt_token")).isNotEqualTo(original.path("attempt_token")); - assertThat(next.path("report")).isEqualTo(original.path("report")); - } - - private void assertClosedSchema(JsonNode schema, String... fields) { - assertThat(schema.path("type").asText()).isEqualTo("object"); - assertThat(schema.path("additionalProperties").asBoolean(true)).isFalse(); - assertThat(schema.path("properties").fieldNames()).toIterable().containsExactlyInAnyOrder(fields); - assertThat(objectMapper.convertValue(schema.path("required"), String[].class)).containsExactlyInAnyOrder(fields); - assertThat(schema.has("oneOf")).isFalse(); - assertThat(schema.has("anyOf")).isFalse(); - } - - private void collectReferences(JsonNode node, List references) { - if (node.isObject() && node.has("$ref")) { - references.add(node.path("$ref").asText()); - } - node.elements().forEachRemaining(child -> collectReferences(child, references)); - } - - private void advance(long seconds) { - now.updateAndGet(instant -> instant.plusSeconds(seconds)); - } - - private String reportStatus(int reportId) { - return jdbcTemplate.queryForObject("SELECT status FROM dining_soldout_report WHERE id = ?", String.class, reportId); - } - - private JsonNode body(ResultActions response) throws Exception { - return body(response.andReturn()); - } - - private JsonNode body(MvcResult response) throws Exception { - return objectMapper.readTree(response.getResponse().getContentAsByteArray()); - } - - private List concurrentlyAtGate(Callable first, Callable second) throws Exception { - CountDownLatch firstLocked = new CountDownLatch(1); - CountDownLatch releaseFirst = new CountDownLatch(1); - CountDownLatch secondAtGate = new CountDownLatch(1); - CountDownLatch secondLocked = new CountDownLatch(1); - AtomicInteger acquisitions = new AtomicInteger(); - var executor = Executors.newFixedThreadPool(2); - var originalAnswer = mockingDetails(sequenceRepository).getMockCreationSettings().getDefaultAnswer(); - try { - doAnswer(invocation -> { - if (acquisitions.getAndIncrement() == 0) { - var sequence = originalAnswer.answer(invocation); - firstLocked.countDown(); - if (!releaseFirst.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - throw new IllegalStateException("첫 요청의 순번 잠금 해제 대기 시간 초과"); - } - return sequence; - } - secondAtGate.countDown(); - var sequence = originalAnswer.answer(invocation); - secondLocked.countDown(); - return sequence; - }).when(sequenceRepository).findForUpdate(); - - var firstResponse = executor.submit(first); - assertThat(firstLocked.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); - var secondResponse = executor.submit(second); - assertThat(secondAtGate.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); - // 첫 요청이 실제 DB 잠금을 소유하는 동안 두 번째 잠금 호출은 반환할 수 없다. - assertThat(secondLocked.await(200, TimeUnit.MILLISECONDS)).isFalse(); - assertThat(secondResponse.isDone()).isFalse(); - releaseFirst.countDown(); - List responses = List.of(firstResponse.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), - secondResponse.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); - assertThat(secondLocked.getCount()).isZero(); - assertThat(acquisitions.get()).isEqualTo(2); - return responses; - } finally { - releaseFirst.countDown(); - executor.shutdownNow(); - try { - assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); - } finally { - reset(sequenceRepository); - } - } - } -} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java new file mode 100644 index 000000000..fd271344c --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java @@ -0,0 +1,299 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.dining.model.DiningReportChange.EventType.CREATED; +import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome.FAILED; +import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome.SUCCEEDED; +import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus.DELIVERED; +import static in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus.QUEUED; +import static in.koreatech.koin.domain.dining.model.DiningReportStatus.PENDING; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_DELIVERY_CONFLICT; +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.Mockito.doAnswer; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.CoopShopAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.DiningAcceptanceFixture; +import in.koreatech.koin.domain.dining.dto.DiningReportActor; +import in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResultRequest; +import in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResultResponse; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportDeliveryOutcome; +import in.koreatech.koin.domain.dining.repository.DiningReportRepository; +import in.koreatech.koin.domain.dining.repository.DiningReportSequenceRepository; +import in.koreatech.koin.domain.dining.service.DiningReportChangeService; +import in.koreatech.koin.domain.dining.service.DiningReportDeliveryService; +import in.koreatech.koin.domain.dining.service.DiningReportService; +import in.koreatech.koin.global.exception.CustomException; + +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class DiningReportDeliveryServiceTest extends AcceptanceTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final Instant START = Instant.parse("2024-01-15T03:00:00Z"); + private static final String IMAGE_URL = "https://test.koreatech.in/upload/COOP/soldout.jpg"; + private static final long TIMEOUT_SECONDS = 10; + + @Autowired + private DiningAcceptanceFixture diningFixture; + + @Autowired + private CoopShopAcceptanceFixture coopShopFixture; + + @Autowired + private DiningReportRepository reportRepository; + + @Autowired + private DiningReportChangeService changeService; + + @Autowired + private DiningReportService reportService; + + @Autowired + private DiningReportDeliveryService deliveryService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @SpyBean + private DiningReportSequenceRepository sequenceRepository; + + @MockBean + private Clock deliveryClock; + + private final AtomicReference now = new AtomicReference<>(START); + + @BeforeEach + void setUp() { + now.set(START); + when(deliveryClock.instant()).thenAnswer(invocation -> now.get()); + when(deliveryClock.getZone()).thenReturn(KST); + when(deliveryClock.withZone(any(ZoneId.class))) + .thenAnswer(invocation -> Clock.fixed(now.get(), invocation.getArgument(0))); + clear(); + new TransactionTemplate(transactionManager).executeWithoutResult(transaction -> { + jdbcTemplate.update("INSERT INTO dining_soldout_report_sequence (id, last_sequence) VALUES (1, 0)"); + coopShopFixture.현재학기(); + }); + } + + @AfterEach + void tearDown() { + clear(); + } + + @Test + void 변경_스냅샷을_순서대로_배정하고_늦은_성공과_재통보는_후속_작업을_완료하지_않는다() { + int reportId = createReport(); + var approved = reportService.approve(reportId, new DiningReportActor("T_EXAMPLE", "U_REVIEWER", "담당자")); + var created = claim(); + assertThat(created.report().status()).isEqualTo(PENDING); + assertThat(deliveryService.claim()).isEmpty(); + advance(61); + var completed = result(created, SUCCEEDED); + assertThat(completed.deliveryState()).isEqualTo(DELIVERED); + assertThat(result(created, SUCCEEDED)).isEqualTo(completed); + var processed = claim(); + assertThat(processed.deliveryId()).isNotEqualTo(created.deliveryId()); + assertThat(processed.report()).isEqualTo(approved.report()); + assertThat(result(created, SUCCEEDED)).isEqualTo(completed); + assertThat(jdbcTemplate.queryForObject(""" + SELECT delivery_state FROM dining_soldout_report_change + WHERE delivery_id = UNHEX(REPLACE(?, '-', '')) + """, String.class, processed.deliveryId().toString())).isEqualTo("IN_PROGRESS"); + assertThat(result(processed, SUCCEEDED).deliveryState()).isEqualTo(DELIVERED); + assertThat(deliveryService.claim()).isEmpty(); + } + + @Test + void 실패는_5초_뒤_재배정하고_재통보로_대기_시간을_연장하지_않는다() { + createReport(); + var task = claim(); + assertThat(result(task, FAILED).deliveryState()).isEqualTo(QUEUED); + advance(4); + assertThat(result(task, FAILED).deliveryState()).isEqualTo(QUEUED); + assertThat(deliveryService.claim()).isEmpty(); + advance(1); + var retry = claim(); + assertSameWork(task, retry); + assertThat(result(retry, SUCCEEDED).deliveryState()).isEqualTo(DELIVERED); + assertThat(deliveryService.claim()).isEmpty(); + } + + @Test + void 배정_60초_만료_즉시_같은_작업을_재배정하고_이전_토큰은_거부한다() { + createReport(); + var task = claim(); + advance(59); + assertThat(deliveryService.claim()).isEmpty(); + advance(1); + var replacement = claim(); + assertSameWork(task, replacement); + assertThatThrownBy(() -> result(task, SUCCEEDED)) + .isInstanceOfSatisfying(CustomException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(DINING_REPORT_DELIVERY_CONFLICT)); + assertThat(result(replacement, SUCCEEDED).deliveryState()).isEqualTo(DELIVERED); + assertThat(deliveryService.claim()).isEmpty(); + } + + @Test + void 실제_순번_잠금에서_경합하는_claim은_하나의_시도만_배정한다() throws Exception { + createReport(); + var claims = concurrentlyClaim(); + assertThat(claims).extracting(Optional::isPresent).containsExactlyInAnyOrder(true, false); + var task = claims.stream().flatMap(Optional::stream).findFirst().orElseThrow(); + assertThat(result(task, SUCCEEDED).deliveryState()).isEqualTo(DELIVERED); + assertThat(deliveryService.claim()).isEmpty(); + } + + @Test + void 구버전_INSERT는_첫_배정에서_작업_UUID와_스냅샷을_저장하고_완료할_수_있다() { + createReport(); + result(claim(), SUCCEEDED); + int legacyReportId = new TransactionTemplate(transactionManager).execute(transaction -> { + var time = currentTime(); + var dining = diningFixture.B코너_점심(time.toLocalDate()); + var report = reportRepository.saveAndFlush( + DiningReport.create(dining, null, IMAGE_URL, UUID.randomUUID(), time)); + long sequence = sequenceRepository.findForUpdate().next(); + jdbcTemplate.update(""" + INSERT INTO dining_soldout_report_change + (sequence, report_id, event_type, status, processing_type, processing_id, occurred_at) + VALUES (?, ?, 'CREATED', 'PENDING', NULL, NULL, ?) + """, sequence, report.getId(), time); + return report.getId(); + }); + assertThat(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM dining_soldout_report_change + WHERE report_id = ? AND delivery_id IS NULL AND report_snapshot IS NULL + """, Integer.class, legacyReportId)).isOne(); + var task = claim(); + assertThat(task.report().reportId()).isEqualTo(legacyReportId); + assertThat(task.report().status()).isEqualTo(PENDING); + assertThat(jdbcTemplate.queryForObject(""" + SELECT report_snapshot FROM dining_soldout_report_change + WHERE report_id = ? AND delivery_id = UNHEX(REPLACE(?, '-', '')) + """, String.class, legacyReportId, task.deliveryId().toString())).isNotBlank(); + assertThat(jdbcTemplate.queryForObject( + "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class)).isEqualTo(2L); + assertThat(result(task, SUCCEEDED).deliveryState()).isEqualTo(DELIVERED); + assertThat(deliveryService.claim()).isEmpty(); + } + + private int createReport() { + return new TransactionTemplate(transactionManager).execute(transaction -> { + var time = currentTime(); + var dining = diningFixture.A코너_점심(time.toLocalDate()); + var report = reportRepository.saveAndFlush( + DiningReport.create(dining, null, IMAGE_URL, UUID.randomUUID(), time)); + changeService.append(List.of(report), CREATED, time); + return report.getId(); + }); + } + + private DiningReportDeliveryResponse claim() { + return deliveryService.claim().orElseThrow(); + } + + private DiningReportDeliveryResultResponse result( + DiningReportDeliveryResponse task, DiningReportDeliveryOutcome outcome + ) { + return deliveryService.recordResult(task.deliveryId(), + new DiningReportDeliveryResultRequest(task.attemptToken(), outcome)); + } + + private void assertSameWork(DiningReportDeliveryResponse original, DiningReportDeliveryResponse next) { + assertThat(next.deliveryId()).isEqualTo(original.deliveryId()); + assertThat(next.attemptToken()).isNotEqualTo(original.attemptToken()); + assertThat(next.report()).isEqualTo(original.report()); + } + + private LocalDateTime currentTime() { + return LocalDateTime.ofInstant(now.get(), KST); + } + + private void advance(long seconds) { + now.updateAndGet(instant -> instant.plusSeconds(seconds)); + } + + private List> concurrentlyClaim() throws Exception { + CountDownLatch firstLocked = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondAtGate = new CountDownLatch(1); + CountDownLatch secondLocked = new CountDownLatch(1); + AtomicInteger acquisitions = new AtomicInteger(); + var executor = Executors.newFixedThreadPool(2); + var originalAnswer = mockingDetails(sequenceRepository).getMockCreationSettings().getDefaultAnswer(); + try { + doAnswer(invocation -> { + if (acquisitions.getAndIncrement() == 0) { + var sequence = originalAnswer.answer(invocation); + firstLocked.countDown(); + if (!releaseFirst.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("첫 호출의 순번 잠금 해제 대기 시간 초과"); + } + return sequence; + } + secondAtGate.countDown(); + var sequence = originalAnswer.answer(invocation); + secondLocked.countDown(); + return sequence; + }).when(sequenceRepository).findForUpdate(); + + var first = executor.submit(deliveryService::claim); + assertThat(firstLocked.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + var second = executor.submit(deliveryService::claim); + assertThat(secondAtGate.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + // 첫 호출이 실제 DB 잠금을 소유하는 동안 두 번째 잠금 호출은 반환할 수 없다. + assertThat(secondLocked.await(200, TimeUnit.MILLISECONDS)).isFalse(); + assertThat(second.isDone()).isFalse(); + releaseFirst.countDown(); + var claims = List.of(first.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + second.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertThat(secondLocked.getCount()).isZero(); + assertThat(acquisitions.get()).isEqualTo(2); + return claims; + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + try { + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } finally { + reset(sequenceRepository); + } + } + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java index 8ecd312fd..2e16d637e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java @@ -27,7 +27,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.BooleanNode; import in.koreatech.koin.acceptance.AcceptanceTest; import in.koreatech.koin.acceptance.fixture.CoopShopAcceptanceFixture; @@ -50,8 +49,6 @@ class DiningSoldOutReportApiTest extends AcceptanceTest { private static final String ADMIN_PATH = "/admin/dining/soldout-reports"; private static final String BOT_HEADER = "X-Koin-Service-Token"; private static final String BOT_TOKEN = "test-dining-report-bot-token"; - private static final String UUID_PATTERN = - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; private static final String IMAGE_DOMAIN = "https://test.koreatech.in/"; private static final String IMAGE_URL = IMAGE_DOMAIN + "upload/COOP/2024/1/15/e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg"; @@ -309,102 +306,6 @@ void setUp() { .andExpect(jsonPath("$.reports[*].report_id").value(contains(latestId, firstId))); } - @Test - void Swagger는_공백시간과_봇_인증을_명세한다() throws Exception { - for (String group : List.of("", "3. Campus API")) { - var request = group.isEmpty() ? get("/v3/api-docs") : get("/v3/api-docs/{group}", group); - JsonNode api = body(mockMvc.perform(request).andExpect(status().isOk())); - JsonNode schemas = api.at("/components/schemas"); - assertThat(api.path("paths").path(BOT_PATH).has("get")).isFalse(); - assertThat(api.path("paths").has(BOT_PATH + "/changes")).isFalse(); - assertThat(schemas.at("/DiningReportDecisionResponse/properties").has("processing_id")).isFalse(); - assertThat(schemas.at("/DiningReportResponse/properties").has("processing_id")).isTrue(); - for (JsonNode time : List.of(schemas.at("/DiningReportCreateResponse/properties/created_at"), - schemas.at("/DiningReportResponse/properties/created_at"), - schemas.at("/DiningReportResponse/properties/processed_at"))) { - assertThat(time.path("type").asText()).isEqualTo("string"); - assertThat(time.path("format").asText()).isNotEqualTo("date-time"); - assertThat(time.path("pattern").asText()).isNotBlank(); - } - assertThat(schemas.at("/DiningReportResponse/properties/processed_at/nullable").asBoolean()).isTrue(); - JsonNode scheme = api.at("/components/securitySchemes/Bot Service Authentication"); - assertThat(scheme.path("type").asText()).isEqualTo("apiKey"); - assertThat(scheme.path("in").asText()).isEqualTo("header"); - assertThat(scheme.path("name").asText()).isEqualTo(BOT_HEADER); - for (JsonNode operation : List.of(api.path("paths").path(BOT_PATH + "/{reportId}").path("get"), - api.path("paths").path(BOT_PATH + "/{reportId}/approve").path("post"), - api.path("paths").path(BOT_PATH + "/{reportId}/reject").path("post"), - api.path("paths").path(BOT_PATH + "/deliveries/claim").path("post"), - api.path("paths").path(BOT_PATH + "/deliveries/{deliveryId}/result").path("post"))) { - assertThat(operation.path("security")).isNotEmpty().allSatisfy(requirement -> { - assertThat(requirement.has("Bot Service Authentication")).isTrue(); - assertThat(requirement.has("Jwt Authentication")).isFalse(); - }); - } - JsonNode claim = api.path("paths").path(BOT_PATH + "/deliveries/claim").path("post"); - assertThat(claim.has("requestBody")).isFalse(); - assertThat(claim.path("parameters")).noneSatisfy(parameter -> - assertThat(parameter.path("name").asText()).isEqualTo("Idempotency-Key")); - assertThat(claim.at("/responses/204/headers/Retry-After").isMissingNode()).isFalse(); - assertClaimSchema(api, claim); - assertResultSchemas(api); - } - } - - private void assertClaimSchema(JsonNode api, JsonNode claim) { - JsonNode response = contentSchema(api, claim.at("/responses/200/content")); - assertClosedObject(response, "delivery_id", "attempt_token", "expires_at", "report"); - JsonNode properties = response.path("properties"); - assertUuid(properties.path("delivery_id")); - assertUuid(properties.path("attempt_token")); - assertThat(properties.path("expires_at").path("format").asText()).isEqualTo("date-time"); - assertThat(schema(api, properties.path("report")).path("properties").has("report_id")).isTrue(); - } - - private void assertResultSchemas(JsonNode api) { - JsonNode operation = api.path("paths").path(BOT_PATH + "/deliveries/{deliveryId}/result").path("post"); - JsonNode request = contentSchema(api, operation.at("/requestBody/content")); - assertClosedObject(request, "attempt_token", "outcome"); - assertUuid(request.path("properties").path("attempt_token")); - assertThat(schema(api, request.path("properties").path("outcome")).path("enum")) - .extracting(JsonNode::asText).containsExactlyInAnyOrder("SUCCEEDED", "FAILED"); - JsonNode response = contentSchema(api, operation.at("/responses/200/content")); - assertClosedObject(response, "delivery_id", "delivery_state"); - assertUuid(response.path("properties").path("delivery_id")); - assertThat(schema(api, response.path("properties").path("delivery_state")).path("enum")) - .extracting(JsonNode::asText).containsExactlyInAnyOrder("QUEUED", "IN_PROGRESS", "DELIVERED"); - } - - private JsonNode contentSchema(JsonNode api, JsonNode content) { - assertThat(content).isNotEmpty(); - JsonNode mediaType = content.has("application/json") ? content.path("application/json") : content.elements().next(); - return schema(api, mediaType.path("schema")); - } - - private JsonNode schema(JsonNode api, JsonNode candidate) { - if (candidate.has("$ref")) { - String reference = candidate.path("$ref").asText(); - assertThat(reference).startsWith("#/components/schemas/"); - candidate = api.at(reference.substring(1)); - assertThat(candidate.isMissingNode()).as("unresolved schema: %s", reference).isFalse(); - } - assertThat(candidate.isObject()).isTrue(); - return candidate; - } - - private void assertClosedObject(JsonNode schema, String... fields) { - assertThat(schema.path("type").asText()).isEqualTo("object"); - assertThat(schema.path("additionalProperties")).isEqualTo(BooleanNode.FALSE); - assertThat(schema.path("required")).extracting(JsonNode::asText).containsExactlyInAnyOrder(fields); - assertThat(schema.path("properties").fieldNames()).toIterable().containsExactlyInAnyOrder(fields); - } - - private void assertUuid(JsonNode schema) { - assertThat(schema.path("type").asText()).isEqualTo("string"); - assertThat(schema.path("format").asText()).isEqualTo("uuid"); - assertThat(schema.path("pattern").asText()).isEqualTo(UUID_PATTERN); - } - private ResultActions submit(String token, Dining target, String key, String imageUrl) throws Exception { var request = post("/dinings/{id}/soldout-reports", target.getId()) .header("Idempotency-Key", key).contentType(MediaType.APPLICATION_JSON)