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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,22 +19,17 @@
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;
import in.koreatech.koin.global.auth.Auth;
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;
Expand All @@ -60,26 +54,20 @@ public interface DiningReportApi {
NOT_FOUND_DINING,
DINING_ALREADY_SOLD_OUT,
DINING_REPORT_ALREADY_SUBMITTED,
IDEMPOTENCY_KEY_CONFLICT,
OPTIMISTIC_LOCKING_FAILURE,
IMAGE_STORAGE_UNAVAILABLE
})
@Operation(summary = "식단 품절 제보 등록", description = """
- 인증된 STUDENT가 한국시간 당일 식단을 학생당 한 번 제보합니다. 다른 학생은 같은 식단을 제보할 수 있습니다.
- 새 제보는 품절된 식단에 접수하지 않습니다. 제보 등록은 식단을 품절 처리하지 않습니다.
- 공용 COOP 업로드의 file_url을 image_url로 전달하면 원본 URL을 그대로 보관합니다.
- 동일 학생이 같은 요청 키·식단·사진 URL로 재시도하면 최초 접수의 201 응답과 제보 ID·생성 시각을 유지합니다.
- 같은 키로 내용을 바꾸거나 같은 식단에 새 키로 제보하면 409를 반환합니다. 처리 후에도 재제보할 수 없습니다.
- 동일 학생이 같은 식단을 다시 제보하면 사진 URL이 같아도 409를 반환합니다. 처리 후에도 재제보할 수 없습니다.
""")
@PostMapping("/dinings/{diningId}/soldout-reports")
ResponseEntity<DiningReportCreateResponse> 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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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초가 되는 한국시간이며, 이 시각부터 같은 작업을 다시 배정할 수 있습니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,10 +26,9 @@ public class DiningReportController implements DiningReportApi {
public ResponseEntity<DiningReportCreateResponse> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@ public interface DiningReportRepository extends Repository<DiningReport, Integer

Optional<DiningReport> findById(Integer id);

Optional<DiningReport> findByReporterIdAndRequestKey(Integer reporterId, UUID requestKey);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM DiningReport r WHERE r.reporterId = :reporterId AND r.requestKey = :requestKey")
Optional<DiningReport> findRequestForUpdate(@Param("reporterId") Integer reporterId,
@Param("requestKey") UUID requestKey);

@Query("SELECT r.dining.id FROM DiningReport r WHERE r.id = :id")
Optional<Integer> findDiningIdById(@Param("id") Integer id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DiningReport> 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<DiningReport> reports = reportRepository.findAllByDiningIdForUpdate(diningId);
if (reports.stream().anyMatch(report -> Objects.equals(report.getReporterId(), reporterId))) {
throw CustomException.of(DINING_REPORT_ALREADY_SUBMITTED);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "이미지 저장소에 연결할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE dining_soldout_report
MODIFY COLUMN request_key BINARY(16) NULL;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
Expand Down
Loading
Loading