diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java index c454ea6b3..878976a9d 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java @@ -8,7 +8,6 @@ import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_STUDENT; import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_USER_TYPE; import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_WEB_ORIGIN; -import static in.koreatech.koin.global.code.ApiResponseCode.IDEMPOTENCY_KEY_CONFLICT; import static in.koreatech.koin.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; import static in.koreatech.koin.global.code.ApiResponseCode.IMAGE_STORAGE_UNAVAILABLE; import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_CSRF_TOKEN; @@ -20,13 +19,10 @@ import static in.koreatech.koin.global.code.ApiResponseCode.UNAUTHORIZED_USER; import static in.koreatech.koin.global.code.ApiResponseCode.WITHDRAWN_USER; -import java.util.UUID; - import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import in.koreatech.koin.domain.dining.dto.DiningReportCreateRequest; import in.koreatech.koin.domain.dining.dto.DiningReportCreateResponse; @@ -34,8 +30,6 @@ import in.koreatech.koin.global.code.ApiResponseCodes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -60,7 +54,6 @@ public interface DiningReportApi { NOT_FOUND_DINING, DINING_ALREADY_SOLD_OUT, DINING_REPORT_ALREADY_SUBMITTED, - IDEMPOTENCY_KEY_CONFLICT, OPTIMISTIC_LOCKING_FAILURE, IMAGE_STORAGE_UNAVAILABLE }) @@ -68,18 +61,13 @@ public interface DiningReportApi { - 인증된 STUDENT가 한국시간 당일 식단을 학생당 한 번 제보합니다. 다른 학생은 같은 식단을 제보할 수 있습니다. - 새 제보는 품절된 식단에 접수하지 않습니다. 제보 등록은 식단을 품절 처리하지 않습니다. - 공용 COOP 업로드의 file_url을 image_url로 전달하면 원본 URL을 그대로 보관합니다. - - 동일 학생이 같은 요청 키·식단·사진 URL로 재시도하면 최초 접수의 201 응답과 제보 ID·생성 시각을 유지합니다. - - 같은 키로 내용을 바꾸거나 같은 식단에 새 키로 제보하면 409를 반환합니다. 처리 후에도 재제보할 수 없습니다. + - 동일 학생이 같은 식단을 다시 제보하면 사진 URL이 같아도 409를 반환합니다. 처리 후에도 재제보할 수 없습니다. """) @PostMapping("/dinings/{diningId}/soldout-reports") ResponseEntity createReport( @Auth(permit = STUDENT) Integer userId, @Parameter(description = "한국시간 당일 식단 ID", example = "1", required = true) @PathVariable Integer diningId, - @Parameter(name = "Idempotency-Key", in = ParameterIn.HEADER, required = true, - description = "학생별 요청 식별 UUID. 같은 요청을 재시도할 때 같은 값을 전달합니다.", - example = "e924c7d3-3757-4cd0-961b-e65c1f6cc8ad", schema = @Schema(type = "string", format = "uuid")) - @RequestHeader("Idempotency-Key") UUID requestKey, @Valid @RequestBody DiningReportCreateRequest request ); } 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 3cd413c7f..68b28f333 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 @@ -53,7 +53,7 @@ public interface DiningReportBotApi { headers = @Header(name = "Retry-After", description = "다음 조회까지 기다릴 시간(초)", schema = @Schema(type = "integer", allowableValues = "5", example = "5"))) @Operation(operationId = "claimDiningReportDelivery", summary = "제보 변경 작업 한 건 가져오기", description = """ - - 서비스 토큰으로 인증하며 본문 없이 호출합니다. 봇 요청에는 Idempotency-Key가 필요하지 않습니다. + - 서비스 토큰으로 인증하며 본문 없이 호출합니다. - 작업이 있으면 200을, 없으면 본문 없는 204와 Retry-After: 5를 반환합니다. 5초 뒤 다시 조회합니다. - report는 해당 변경의 고정된 내용입니다. 같은 제보의 이전 작업이 완료되어야 다음 변경을 배정합니다. - expires_at은 배정 후 60초가 되는 한국시간이며, 이 시각부터 같은 작업을 다시 배정할 수 있습니다. diff --git a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportController.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportController.java index 99e5f946e..a9c18b446 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportController.java +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportController.java @@ -2,14 +2,11 @@ import static in.koreatech.koin.domain.user.model.UserType.STUDENT; -import java.util.UUID; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.domain.dining.dto.DiningReportCreateRequest; @@ -29,10 +26,9 @@ public class DiningReportController implements DiningReportApi { public ResponseEntity createReport( @Auth(permit = STUDENT) Integer userId, @PathVariable Integer diningId, - @RequestHeader("Idempotency-Key") UUID requestKey, @Valid @RequestBody DiningReportCreateRequest request ) { return ResponseEntity.status(HttpStatus.CREATED) - .body(diningReportService.create(userId, diningId, requestKey, request)); + .body(diningReportService.create(userId, diningId, request)); } } diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateResponse.java index e39deda85..f314c6404 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateResponse.java +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateResponse.java @@ -14,12 +14,12 @@ @JsonNaming(SnakeCaseStrategy.class) public record DiningReportCreateResponse( - @Schema(description = "제보 ID. 동일 요청 키의 재시도에서도 최초 ID를 유지합니다.", example = "1", requiredMode = REQUIRED) + @Schema(description = "제보 ID", example = "1", requiredMode = REQUIRED) Integer reportId, - @Schema(description = "최초 접수 상태. 재시도에서도 최초 접수 결과를 반환합니다.", example = "PENDING", + @Schema(description = "접수 상태", example = "PENDING", allowableValues = {"PENDING"}, requiredMode = REQUIRED) DiningReportStatus status, - @Schema(description = "최초 접수 시각 (한국시간). 재시도에서도 유지합니다.", example = "2026-10-02 12:00:00", + @Schema(description = "접수 시각 (한국시간)", example = "2026-10-02 12:00:00", implementation = String.class, type = "string", pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", requiredMode = REQUIRED) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReport.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReport.java index 886cbe268..eed701010 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/DiningReport.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReport.java @@ -24,8 +24,7 @@ @Entity @NoArgsConstructor(access = PROTECTED) @Table(name = "dining_soldout_report", uniqueConstraints = { - @UniqueConstraint(name = "uk_dining_report_student", columnNames = {"reporter_id", "dining_id"}), - @UniqueConstraint(name = "uk_dining_report_request", columnNames = {"reporter_id", "request_key"}) + @UniqueConstraint(name = "uk_dining_report_student", columnNames = {"reporter_id", "dining_id"}) }) public class DiningReport { @@ -69,22 +68,17 @@ public class DiningReport { @Column(name = "processed_at", columnDefinition = "DATETIME") private LocalDateTime processedAt; - @Column(name = "request_key", columnDefinition = "BINARY(16)", nullable = false) - private UUID requestKey; - @Column(name = "created_at", columnDefinition = "DATETIME", nullable = false, updatable = false) private LocalDateTime createdAt; @Column(name = "updated_at", columnDefinition = "DATETIME", nullable = false) private LocalDateTime updatedAt; - public static DiningReport create(Dining dining, Integer reporterId, String imageUrl, - UUID requestKey, LocalDateTime now) { + public static DiningReport create(Dining dining, Integer reporterId, String imageUrl, LocalDateTime now) { DiningReport report = new DiningReport(); report.dining = dining; report.reporterId = reporterId; report.imageUrl = imageUrl; - report.requestKey = requestKey; report.status = DiningReportStatus.PENDING; report.createdAt = now; report.updatedAt = now; diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java index 840d147de..d662683d9 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java @@ -20,13 +20,6 @@ public interface DiningReportRepository extends Repository findById(Integer id); - Optional findByReporterIdAndRequestKey(Integer reporterId, UUID requestKey); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT r FROM DiningReport r WHERE r.reporterId = :reporterId AND r.requestKey = :requestKey") - Optional findRequestForUpdate(@Param("reporterId") Integer reporterId, - @Param("requestKey") UUID requestKey); - @Query("SELECT r.dining.id FROM DiningReport r WHERE r.id = :id") Optional findDiningIdById(@Param("id") Integer id); diff --git a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportService.java b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportService.java index 5c7f5a200..d9fb292cb 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportService.java +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportService.java @@ -16,7 +16,6 @@ import java.time.ZoneId; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.UUID; import org.springframework.context.ApplicationEventPublisher; @@ -64,19 +63,9 @@ public class DiningReportService { private final S3Client s3Client; private final Clock clock; - public DiningReportCreateResponse create(Integer reporterId, Integer diningId, UUID requestKey, - DiningReportCreateRequest request) { - Optional previous = reportRepository.findByReporterIdAndRequestKey(reporterId, requestKey); - if (previous.isPresent()) { - return replayCreation(previous.get(), diningId, request.imageUrl()); - } - + public DiningReportCreateResponse create(Integer reporterId, Integer diningId, DiningReportCreateRequest request) { validateImage(request.imageUrl()); Dining dining = lockDining(diningId); - previous = reportRepository.findRequestForUpdate(reporterId, requestKey); - if (previous.isPresent()) { - return replayCreation(previous.get(), diningId, request.imageUrl()); - } List reports = reportRepository.findAllByDiningIdForUpdate(diningId); if (reports.stream().anyMatch(report -> Objects.equals(report.getReporterId(), reporterId))) { throw CustomException.of(DINING_REPORT_ALREADY_SUBMITTED); @@ -88,14 +77,11 @@ public DiningReportCreateResponse create(Integer reporterId, Integer diningId, U if (dining.getSoldOut() != null) { throw CustomException.of(DINING_ALREADY_SOLD_OUT); } - DiningReport report = DiningReport.create(dining, reporterId, request.imageUrl(), requestKey, now); + DiningReport report = DiningReport.create(dining, reporterId, request.imageUrl(), now); try { reportRepository.saveAndFlush(report); } catch (DataIntegrityViolationException exception) { String detail = exception.getMostSpecificCause().getMessage(); - if (detail != null && detail.contains("uk_dining_report_request")) { - throw CustomException.of(IDEMPOTENCY_KEY_CONFLICT); - } if (detail != null && detail.contains("uk_dining_report_student")) { throw CustomException.of(DINING_REPORT_ALREADY_SUBMITTED); } @@ -170,13 +156,6 @@ private DiningReportDecisionResponse decide(Integer reportId, DiningReportActor affected.stream().map(DiningReport::getId).toList(), false); } - private DiningReportCreateResponse replayCreation(DiningReport report, Integer diningId, String imageUrl) { - if (!report.getDining().getId().equals(diningId) || !report.getImageUrl().equals(imageUrl)) { - throw CustomException.of(IDEMPOTENCY_KEY_CONFLICT); - } - return DiningReportCreateResponse.from(report); - } - private Dining lockDining(Integer diningId) { return diningRepository.findByIdForUpdate(diningId) .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING)); 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 2f7a11b22..add108fd9 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -17,7 +17,6 @@ public enum ApiResponseCode { 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, "현재 작업 시도 또는 이미 접수한 결과와 일치하지 않습니다."), IMAGE_STORAGE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이미지 저장소에 연결할 수 없습니다."), diff --git a/src/main/resources/db/migration/V15__relax_dining_report_request_key.sql b/src/main/resources/db/migration/V15__relax_dining_report_request_key.sql new file mode 100644 index 000000000..49603aaf6 --- /dev/null +++ b/src/main/resources/db/migration/V15__relax_dining_report_request_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE dining_soldout_report + MODIFY COLUMN request_key BINARY(16) NULL; diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java index fd271344c..2c18de7a7 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryServiceTest.java @@ -21,7 +21,6 @@ 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; @@ -187,7 +186,7 @@ void tearDown() { var time = currentTime(); var dining = diningFixture.B코너_점심(time.toLocalDate()); var report = reportRepository.saveAndFlush( - DiningReport.create(dining, null, IMAGE_URL, UUID.randomUUID(), time)); + DiningReport.create(dining, null, IMAGE_URL, time)); long sequence = sequenceRepository.findForUpdate().next(); jdbcTemplate.update(""" INSERT INTO dining_soldout_report_change @@ -218,7 +217,7 @@ private int createReport() { var time = currentTime(); var dining = diningFixture.A코너_점심(time.toLocalDate()); var report = reportRepository.saveAndFlush( - DiningReport.create(dining, null, IMAGE_URL, UUID.randomUUID(), time)); + DiningReport.create(dining, null, IMAGE_URL, time)); changeService.append(List.of(report), CREATED, time); return report.getId(); }); 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 2e16d637e..c04b295db 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java @@ -15,7 +15,6 @@ import java.time.ZoneId; import java.util.List; import java.util.Map; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -123,39 +122,38 @@ void setUp() { } @Test - void 접수는_판매상태를_유지하고_재시도와_중복을_구분한다() throws Exception { - String key = UUID.randomUUID().toString(); - int id = body(submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isCreated()) + void 접수는_판매상태를_유지하고_같은학생의_같은식단_중복을_막는다() throws Exception { + int id = body(submit(studentToken, dining, IMAGE_URL).andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value("PENDING"))).path("report_id").asInt(); botDetail(id).andExpect(jsonPath("$.status").value("PENDING")) .andExpect(jsonPath("$.image_url").value(IMAGE_URL)); assertThat(storedDining().getSoldOut()).isNull(); - submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isCreated()) - .andExpect(jsonPath("$.report_id").value(id)); - submit(studentToken, dining, key, IMAGE_URL.replace("soldout.jpg", "other.jpg")) - .andExpect(status().isConflict()); - submit(studentToken, otherDining, key, IMAGE_URL).andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_CONFLICT")); - submit(studentToken, dining, UUID.randomUUID().toString(), IMAGE_URL).andExpect(status().isConflict()); + submit(studentToken, dining, IMAGE_URL).andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DINING_REPORT_ALREADY_SUBMITTED")); + submit(studentToken, dining, IMAGE_URL.replace("soldout.jpg", "other.jpg")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DINING_REPORT_ALREADY_SUBMITTED")); + int otherId = body(submit(studentToken, otherDining, IMAGE_URL).andExpect(status().isCreated())) + .path("report_id").asInt(); + assertThat(otherId).isNotEqualTo(id); } @Test void 접수는_학생과_당일식단과_업로드사진을_검증한다() throws Exception { - String key = UUID.randomUUID().toString(); for (String token : List.of(generalToken, coopToken, adminToken)) { - submit(token, dining, key, IMAGE_URL).andExpect(status().isForbidden()); + submit(token, dining, IMAGE_URL).andExpect(status().isForbidden()); } - submit(null, dining, key, IMAGE_URL).andExpect(status().isUnauthorized()); + submit(null, dining, IMAGE_URL).andExpect(status().isUnauthorized()); for (int days : List.of(-1, 1)) { Dining anotherDate = diningFixture.A코너_점심(dining.getDate().plusDays(days)); - submit(studentToken, anotherDate, key, IMAGE_URL).andExpect(status().isBadRequest()); + submit(studentToken, anotherDate, IMAGE_URL).andExpect(status().isBadRequest()); } - submit(studentToken, dining, key, "https://outside.example/soldout.jpg").andExpect(status().isBadRequest()); + submit(studentToken, dining, "https://outside.example/soldout.jpg").andExpect(status().isBadRequest()); when(s3Client.doesFileExist(anyString())).thenReturn(false); - submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isBadRequest()); + submit(studentToken, dining, IMAGE_URL).andExpect(status().isBadRequest()); student.updateAuthenticationStatus(false); entityManager.flush(); - submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isForbidden()) + submit(studentToken, dining, IMAGE_URL).andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("FORBIDDEN_STUDENT")); } @@ -214,18 +212,16 @@ void setUp() { @Test void 품절해제후_새학생_제보는_과거_처리묶음과_분리한다() throws Exception { - String key = UUID.randomUUID().toString(); - JsonNode created = body(submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isCreated())); - int selectedId = created.path("report_id").asInt(); + int selectedId = createReport(studentToken, dining); int automaticId = createReport(otherStudentToken, dining); String previousGroup = body(decide(selectedId, "approve", "U_FIRST").andExpect(status().isOk())) .path("report").path("processing_id").asText(); - submit(thirdStudentToken, dining, UUID.randomUUID().toString(), IMAGE_URL).andExpect(status().isConflict()) + submit(thirdStudentToken, dining, IMAGE_URL).andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("DINING_ALREADY_SOLD_OUT")); setSoldOut(false); int newId = createReport(thirdStudentToken, dining); - assertThat(body(submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isCreated()))) - .isEqualTo(created); + submit(studentToken, dining, IMAGE_URL).andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DINING_REPORT_ALREADY_SUBMITTED")); decide(selectedId, "approve", "U_OTHER").andExpect(status().isOk()) .andExpect(jsonPath("$.already_processed").value(true)) .andExpect(jsonPath("$.report.processing_id").value(previousGroup)) @@ -267,7 +263,7 @@ void setUp() { setSoldOut(false); assertThat(storedDining().getSoldOut()).isNull(); botDetail(firstId).andExpect(jsonPath("$.status").value("REJECTED")); - submit(otherStudentToken, dining, UUID.randomUUID().toString(), IMAGE_URL).andExpect(status().isConflict()); + submit(otherStudentToken, dining, IMAGE_URL).andExpect(status().isConflict()); } @Test @@ -306,9 +302,9 @@ void setUp() { .andExpect(jsonPath("$.reports[*].report_id").value(contains(latestId, firstId))); } - private ResultActions submit(String token, Dining target, String key, String imageUrl) throws Exception { + private ResultActions submit(String token, Dining target, String imageUrl) throws Exception { var request = post("/dinings/{id}/soldout-reports", target.getId()) - .header("Idempotency-Key", key).contentType(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("image_url", imageUrl))); if (token != null) { request.header("Authorization", "Bearer " + token); @@ -317,7 +313,7 @@ private ResultActions submit(String token, Dining target, String key, String ima } private int createReport(String token, Dining target) throws Exception { - return body(submit(token, target, UUID.randomUUID().toString(), IMAGE_URL) + return body(submit(token, target, IMAGE_URL) .andExpect(status().isCreated())).path("report_id").asInt(); } 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 2e293eb32..89e1fb504 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java @@ -21,7 +21,6 @@ import java.time.ZoneId; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -268,7 +267,7 @@ SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) WHERE table_schema = DATABASE() AND table_name = 'dining_soldout_report' GROUP BY index_name """, String.class)).containsExactlyInAnyOrder( - "id", "reporter_id,dining_id", "reporter_id,request_key", "created_at,id", + "id", "reporter_id,dining_id", "created_at,id", "status,created_at,id", "dining_id,status,id", "processing_id,id", "source_report_id"); Integer secondDiningId = transactionTemplate.execute(status -> @@ -444,7 +443,6 @@ private void process(String action, Integer targetDiningId, Integer reportId) { private int submit(String token, Integer targetDiningId) throws Exception { return mockMvc.perform(post("/dinings/{diningId}/soldout-reports", targetDiningId) .header("Authorization", "Bearer " + token) - .header("Idempotency-Key", UUID.randomUUID().toString()) .contentType(MediaType.APPLICATION_JSON) .content("{\"image_url\":\"" + IMAGE_URL + "\"}")) .andReturn().getResponse().getStatus(); @@ -454,7 +452,7 @@ private List> reportRows() { return jdbcTemplate.queryForList(""" SELECT id, reporter_id, image_url, status, processing_type, HEX(processing_id) AS processing_id, source_report_id, processor_workspace_id, processor_user_id, processor_name, - processed_at, created_at, updated_at, HEX(request_key) AS request_key + processed_at, created_at, updated_at FROM dining_soldout_report WHERE dining_id = ? ORDER BY id 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 44d73f474..165ef1312 100644 --- a/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java @@ -37,7 +37,7 @@ class DiningSoldOutReportMigrationTest { .withPassword("test"); @Test - void V14는_확인된_이력을_보존하고_최신_미완료_작업만_이관하며_구버전_추가를_배정한다() throws Exception { + void V14와_V15는_기존이력을_보존하고_요청키없는_접수도_학생중복을_막는다() throws Exception { migrateTo("11"); try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { statement.executeUpdate(""" @@ -198,6 +198,40 @@ SELECT COUNT(*) FROM dining_soldout_report_change WHERE sequence = 1 AND report_ AND processing_id = UNHEX(REPEAT('02', 16)) AND delivery_state = 'DELIVERED' """)).isOne(); } + migrateTo("15"); + try (SessionFactory sessionFactory = validateSchema(); + Connection connection = getConnection(); Statement statement = connection.createStatement()) { + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report + WHERE id BETWEEN 301 AND 306 AND request_key = UNHEX(REPEAT('01', 16)) + """)).isEqualTo(6); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report WHERE id = 301 AND reporter_id IS NULL + AND status = 'APPROVED' AND processor_name = '담당자' AND processing_type = 'MANUAL' + AND processing_id = UNHEX(REPEAT('02', 16)) + """)).isOne(); + statement.executeUpdate(""" + INSERT INTO users (id, password, user_type, anonymous_nickname) + VALUES (102, 'test', 'STUDENT', '새학생1'), (103, 'test', 'STUDENT', '새학생2') + """); + statement.executeUpdate(""" + INSERT INTO dining_menus (id, date, type, place, menu) + VALUES (202, '2026-10-02', 'LUNCH', 'B코너', '새 메뉴') + """); + statement.executeUpdate(""" + INSERT INTO dining_soldout_report (id, reporter_id, dining_id, image_url, created_at, updated_at) + VALUES (307, 102, 201, 'https://example.com/new.jpg', '2026-10-02 12:45:00', '2026-10-02 12:45:00'), + (308, 103, 201, 'https://example.com/new.jpg', '2026-10-02 12:45:00', '2026-10-02 12:45:00'), + (309, 102, 202, 'https://example.com/new.jpg', '2026-10-02 12:45:00', '2026-10-02 12:45:00') + """); + assertThatThrownBy(() -> statement.executeUpdate(""" + INSERT INTO dining_soldout_report (reporter_id, dining_id, image_url, created_at, updated_at) + VALUES (102, 201, 'https://example.com/new.jpg', '2026-10-02 12:45:00', '2026-10-02 12:45:00') + """)).isInstanceOf(SQLException.class).hasMessageContaining("uk_dining_report_student"); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report WHERE reporter_id IN (102, 103) AND request_key IS NULL + """)).isEqualTo(3); + } } private static void insertPendingReport(Statement statement, int reportId, long sequence) throws SQLException { diff --git a/src/test/java/in/koreatech/koin/unit/domain/dining/service/DiningReportImageValidationTest.java b/src/test/java/in/koreatech/koin/unit/domain/dining/service/DiningReportImageValidationTest.java index 45532509b..723f4cb73 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/dining/service/DiningReportImageValidationTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/dining/service/DiningReportImageValidationTest.java @@ -18,7 +18,6 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; @@ -88,15 +87,12 @@ void setUp() { Integer reporterId = 42; Integer diningId = 1; - UUID requestKey = UUID.randomUUID(); Dining dining = Dining.builder().date(LocalDate.now(clock)).type(DiningType.LUNCH) .place("A코너").menu("[\"돈까스\"]").build(); - when(reportRepository.findByReporterIdAndRequestKey(reporterId, requestKey)).thenReturn(Optional.empty()); - when(reportRepository.findRequestForUpdate(reporterId, requestKey)).thenReturn(Optional.empty()); when(reportRepository.findAllByDiningIdForUpdate(diningId)).thenReturn(List.of()); when(diningRepository.findByIdForUpdate(diningId)).thenReturn(Optional.of(dining)); - var response = service.create(reporterId, diningId, requestKey, new DiningReportCreateRequest(imageUrl)); + var response = service.create(reporterId, diningId, new DiningReportCreateRequest(imageUrl)); ArgumentCaptor reportCaptor = ArgumentCaptor.forClass(DiningReport.class); verify(reportRepository).saveAndFlush(reportCaptor.capture()); @@ -122,18 +118,17 @@ void setUp() { Dining dining = Dining.builder().date(LocalDate.parse(diningDate)).type(DiningType.LUNCH) .place("A코너").menu("[\"돈까스\"]").build(); when(diningRepository.findByIdForUpdate(1)).thenReturn(Optional.of(dining)); - UUID requestKey = UUID.randomUUID(); var request = new DiningReportCreateRequest(imageUrl); if (!allowed) { - assertThatThrownBy(() -> boundaryService.create(42, 1, requestKey, request)) + assertThatThrownBy(() -> boundaryService.create(42, 1, request)) .isInstanceOfSatisfying(CustomException.class, exception -> assertThat(exception.getErrorCode()).isEqualTo(DINING_REPORT_DATE_NOT_ALLOWED)); verify(reportRepository, never()).saveAndFlush(any(DiningReport.class)); return; } - var response = boundaryService.create(42, 1, requestKey, request); + var response = boundaryService.create(42, 1, request); assertThat(response.status()).isEqualTo(DiningReportStatus.PENDING); assertThat(response.createdAt().toLocalDate()).isEqualTo(dining.getDate()); verify(reportRepository).saveAndFlush(any(DiningReport.class)); @@ -149,7 +144,7 @@ void setUp() { "https://static.koreatech.in/upload/SHOPS/soldout.jpg" }) void 허용되지_않은_URL은_S3_확인과_식단_잠금_전에_거부한다(String imageUrl) { - assertThatThrownBy(() -> service.create(42, 1, UUID.randomUUID(), new DiningReportCreateRequest(imageUrl))) + assertThatThrownBy(() -> service.create(42, 1, new DiningReportCreateRequest(imageUrl))) .isInstanceOf(CustomException.class) .hasMessage(INVALID_REPORT_IMAGE.getMessage()); verifyNoInteractions(amazonS3, diningRepository);