From 6d7c3bdd7a96b4a77a705223224c55e10dd00a33 Mon Sep 17 00:00:00 2001 From: Junyoung Lee Date: Fri, 2 Oct 2026 17:51:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9B=B9=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=EC=99=80=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=9C=A0=ED=98=95=20=EB=AC=B4=EA=B4=80=20=EB=82=B4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(#2?= =?UTF-8?q?468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 웹 세션 조회 API와 회원 유형 무관 내 정보 조회 API 추가 - GET /v2/web/auth/session: 비로그인도 200(authenticated=false)으로 응답하고, refresh 세션이 유효하면 access 만료와 무관하게 로그인으로 응답하며 CSRF 쿠키를 복구한다. 토큰은 회전하지 않는다. 세션이 확정적으로 사라졌을 때만 인증 쿠키를 만료시킨다. - GET /v2/users/me/profile: 일반·학생·총학생회 공통 내 정보. 학생은 student_number, major 포함. - 기존 /user/auth, /user/student/me, /v2/users/me 계약은 변경하지 않는다. * refactor: /csrf를 /session으로 교체하고 내 정보 API를 /v3/users/me로 변경 - 웹 전용이고 클라이언트가 쓰지 않던 GET /v2/web/auth/csrf를 제거한다. /session이 CSRF 쿠키 복구를 포함한다. - 세션 조회는 SQL 트랜잭션 없이 Redis 확인 후 사용자 단건 조회만 한다. - 회원 유형 무관 내 정보 API 경로를 /v2/users/me/profile에서 /v3/users/me로 변경한다. * refactor: 세션 조회를 부작용 없는 읽기로 정리하고 쿠키 삭제 판단을 단위 테스트로 검증 - 비밀번호 변경 검사와 세션 삭제를 제거해 GET이 세션을 변경하지 않게 한다(access 유효 기간 정책과도 일치). - refresh 조회와 access 조회를 private 메서드로 분리하고, 사용자 단건 조회를 한 번만 하도록 access 세션 조회를 공유한다. - WebAuthSession.isExpired()를 추가한다. * refactor: 세션 조회를 별도 서비스로 분리하고 쿠키 처리 결정을 컨트롤러로 옮긴다 - WebSessionService를 추가해 읽기 전용 세션 조회를 WebAuthService에서 분리한다. - 서비스 결과는 쿠키 삭제 여부 대신 도메인 상태(AUTHENTICATED, ANONYMOUS, SESSION_LOST)만 돌려주고, 쿠키 쓰기·삭제는 컨트롤러가 결정한다. - 세션 조회 단위 테스트를 WebSessionServiceTest로 옮기고 access 보조 확인 등 케이스를 보강한다. * fix: 세션 만료 여부 메서드를 Redis 직렬화에서 제외한다 * chore: 서비스 응답값 변수화 * refactor: 학생 판별 로직 엔티티로 이동 * chore: student 조회값 인라인 해제 --------- Co-authored-by: 신관규 --- docs/web-cookie-auth.md | 26 ++- .../koin/domain/user/controller/UserApi.java | 19 ++ .../user/controller/UserController.java | 9 + .../domain/user/dto/UserProfileResponse.java | 71 ++++++++ .../koin/domain/user/model/User.java | 5 + .../koin/domain/user/service/UserService.java | 12 ++ .../user/web/controller/WebAuthApi.java | 23 +-- .../web/controller/WebAuthController.java | 24 ++- .../user/web/dto/WebCsrfTokenResponse.java | 16 -- .../user/web/dto/WebSessionResponse.java | 26 +++ .../domain/user/web/model/WebAuthSession.java | 13 +- .../user/web/service/WebAuthService.java | 28 +-- .../user/web/service/WebSessionResult.java | 37 ++++ .../user/web/service/WebSessionService.java | 78 ++++++++ .../config/WebAuthOpenApiCustomizer.java | 6 +- .../acceptance/domain/StudentApiTest.java | 27 +++ .../acceptance/domain/WebAuthApiTest.java | 123 ++++++++++++- .../acceptance/domain/WebAuthHttpTest.java | 2 +- .../domain/WebAuthOpenApiContractTest.java | 16 +- .../web/WebAuthControllerFailureTest.java | 14 +- .../domain/user/web/WebAuthFailureTest.java | 21 --- .../user/web/WebSessionServiceTest.java | 170 ++++++++++++++++++ .../config/WebAuthOpenApiCustomizerTest.java | 4 +- 23 files changed, 676 insertions(+), 94 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java delete mode 100644 src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java create mode 100644 src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java diff --git a/docs/web-cookie-auth.md b/docs/web-cookie-auth.md index 5584363abc..cbd0efaac5 100644 --- a/docs/web-cookie-auth.md +++ b/docs/web-cookie-auth.md @@ -9,9 +9,10 @@ | 요청 | 입력 | 성공 응답 | | --- | --- | --- | | `POST /v2/web/auth/login` | JSON `login_id`, `login_pw`, `auto_login` | 201, 인증 쿠키 2개와 CSRF 일반 쿠키, `user_type`·`csrf_token` | -| `GET /v2/web/auth/csrf` | refresh 쿠키 | 200, CSRF 일반 쿠키 복구, `csrf_token` | | `POST /v2/web/auth/refresh` | refresh 쿠키, `X-CSRF-Token` | 201, 교체된 인증 쿠키 2개와 CSRF 일반 쿠키, `user_type`·`csrf_token` | | `POST /v2/web/auth/logout` | refresh 쿠키, `X-CSRF-Token` | 204, 현재 웹 세션 폐기 및 쿠키 삭제 | +| `GET /v2/web/auth/session` | access·refresh 쿠키(둘 다 선택) | 200 항상. `authenticated`, `user_type`, `csrf_token`. 유효하면 CSRF 쿠키 복구 | +| `GET /v3/users/me` | access 쿠키(또는 Bearer) | 200, 일반·학생·총학생회 공통 내 정보. 학생은 `student_number`, `major` 포함 | 웹 인증 API는 허용된 `Origin` 또는 `Referer`가 필요하다. 로그인 본문은 `application/json`만 받는다. `login_pw`는 기존 로그인과 동일하게 SHA-256 처리한 비밀번호를 전달한다. @@ -23,7 +24,7 @@ HMAC-SHA256 서명으로 구성되며 세션 ID는 CSRF 토큰에 노출하지 현재 Redis 세션의 토큰을 함께 검증한다. 쿠키에 담긴 값만으로 인증하거나 쿠키와 헤더의 단순 일치만 확인하지 않는다. 웹 코드는 CSRF 일반 쿠키를 읽어 쿠키 인증 POST/PUT/PATCH/DELETE 요청의 `X-CSRF-Token` 헤더에 넣는다. CSRF 토큰을 별도 state나 localStorage에 저장할 필요는 없다. 기존 JSON의 `csrf_token`은 호환성을 위해 유지한다. -CSRF 쿠키가 없으면 `/csrf`를 호출해 복구한다. 단순 새로고침마다 이 API를 호출할 필요는 없다. +CSRF 쿠키가 없으면 `/session`을 호출해 복구한다(세션이 유효할 때 CSRF 쿠키를 다시 발급한다). 단순 새로고침마다 호출할 필요는 없다. 로그인 시에는 기존 세션이 없으므로 허용 출처 검사와 JSON Content-Type 제한으로 로그인 CSRF를 방어한다. @@ -36,7 +37,7 @@ CSRF 쿠키가 없으면 `/csrf`를 호출해 복구한다. 단순 새로고침 5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다. 6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. -로그인·재발급·로그아웃·CSRF 조회는 만료된 access 쿠키의 영향을 받지 않는다. +로그인·재발급·로그아웃·세션 조회는 만료된 access 쿠키의 영향을 받지 않는다. 일반 기능 API에서는 명시적인 `Authorization` 헤더가 있으면 기존 헤더 인증만 사용한다. 잘못된 헤더를 쿠키 인증으로 대체하지 않으므로, 웹 전환 시 예전 헤더 주입 코드도 제거해야 한다. 기존 앱 토큰을 웹 쿠키로 넣거나 웹 토큰을 기존 앱 인증 경로로 전달하는 것은 허용하지 않는다. @@ -52,6 +53,23 @@ API 주소 직접 입력은 403이 될 수 있다. SSR/프록시도 모든 쿠 `/user/check/login`처럼 토큰을 query로 받는 기존 전용 API는 쿠키 인증으로 전환하지 않는다. 웹의 로그인 상태 확인은 쿠키와 함께 `/user/auth`를 사용한다. +### 세션 조회 (`/v2/web/auth/session`) + +웹은 로그인 상태를 알기 위해 이 API를 사용한다. 비로그인도 오류가 아니라 `authenticated: false`인 200으로 응답하므로 +401을 정상 응답으로 해석하거나 `/user/auth` 401 → `/refresh` → 재시도를 클라이언트가 조합할 필요가 없다. + +- refresh 쿠키의 세션이 유효하면 access가 만료되었어도 `authenticated: true`이고, 일치하지 않으면 access 쿠키로 확인한다. 토큰은 회전하지 않는다. access는 이후 API 요청이 401일 때 `/refresh`로 재발급한다. +- 세션이 유효하면 CSRF 일반 쿠키를 복구한다. 이 API가 기존 `/csrf` 조회를 대체한다. +- 세션이 저장소에서 사라졌거나(만료·로그아웃·비밀번호 변경·탈퇴) 쿠키 형식이 잘못되면 `authenticated: false`로 응답하며 access·refresh·CSRF 쿠키를 만료시킨다. 이후 "CSRF 쿠키 없음 = 세션 없음"으로 판단할 수 있다. +- refresh 값이 세션과 일치하지 않는 경우는 다른 탭이 이미 회전시켰을 수 있으므로 쿠키를 지우지 않는다. +- 다른 웹 인증 API와 같이 허용된 `Origin` 또는 `Referer`가 필요하고 `Cache-Control: no-store`로 응답한다. + +### 내 정보 조회 (`/v3/users/me`) + +일반(`GENERAL`)·학생(`STUDENT`)·총학생회(`COUNCIL`)가 같은 엔드포인트로 조회한다. 학생·총학생회는 `student_number`, `major`가 함께 내려오고 일반 회원은 두 필드가 null이다. +기존 `/user/student/me`, `/v2/users/me`와 앱 계약은 그대로 유지한다. + + ## 쿠키와 만료 설정 | 항목 | 운영(`prod`) 기본값 | @@ -105,7 +123,7 @@ refresh 원문은 저장하지 않고 SHA-256 해시를 저장한다. 재발급 웹 access JWT에도 세션 ID를 넣고 요청마다 Redis 세션을 확인하므로 웹 로그아웃 후 남은 access도 사용할 수 없다. 웹 인증 시 DB에서도 계정의 존재 여부를 확인하므로 `@UserId` API도 탈퇴 계정의 쿠키를 받지 않는다. 이 때문에 쿠키 인증은 Redis와 사용자 DB 가용성에 의존하며, 조회 실패 시 인증을 허용하지 않는다. -Redis 전용 CSRF 조회·로그아웃에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. +Redis 전용 로그아웃과 세션 조회(사용자 조회는 트랜잭션 없이 단건 읽기)에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. Redis의 원자적 비교·교체로 같은 refresh의 동시 갱신은 하나만 성공한다. 먼저 읽은 상태가 다른 요청에 의해 변경되면 409, 이미 교체된 refresh를 새로 제출하면 401이다. 실패 응답은 쿠키를 삭제하거나 덮어쓰지 않는다. diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java index 8c04f77a56..d5fcd2c245 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserApi.java @@ -25,6 +25,7 @@ import in.koreatech.koin.domain.user.dto.UserRegisterRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordByEmailRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordBySmsRequest; +import in.koreatech.koin.domain.user.dto.UserProfileResponse; import in.koreatech.koin.domain.user.dto.UserResponse; import in.koreatech.koin.domain.user.dto.UserTypeResponse; import in.koreatech.koin.domain.user.dto.UserUpdatePasswordRequest; @@ -57,6 +58,24 @@ ResponseEntity getUser( @Auth(permit = {GENERAL}) Integer userId ); + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))) + } + ) + @Operation( + summary = "내 정보 조회 V2 (회원 유형 무관)", + description = "일반·학생·총학생회 모두 이 엔드포인트로 조회합니다. 학생·총학생회는 student_number, major가 함께 내려오고 일반 회원은 null입니다." + ) + @SecurityRequirement(name = "Jwt Authentication") + @GetMapping("/v3/users/me") + ResponseEntity getProfile( + @Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java index 34aeb28b7e..076d3e89f0 100644 --- a/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java +++ b/src/main/java/in/koreatech/koin/domain/user/controller/UserController.java @@ -27,6 +27,7 @@ import in.koreatech.koin.domain.user.dto.UserRegisterRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordByEmailRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordBySmsRequest; +import in.koreatech.koin.domain.user.dto.UserProfileResponse; import in.koreatech.koin.domain.user.dto.UserResponse; import in.koreatech.koin.domain.user.dto.UserTypeResponse; import in.koreatech.koin.domain.user.dto.UserUpdatePasswordRequest; @@ -50,6 +51,14 @@ public ResponseEntity getUser( return ResponseEntity.ok().body(userResponse); } + @GetMapping("/v3/users/me") + public ResponseEntity getProfile( + @Auth(permit = {GENERAL, STUDENT, COUNCIL}) Integer userId + ) { + UserProfileResponse response = userService.getProfile(userId); + return ResponseEntity.ok().body(response); + } + @GetMapping("/user/auth") public ResponseEntity getUserType( @Auth(permit = {GENERAL, STUDENT, OWNER, COOP, COUNCIL}) Integer userId diff --git a/src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java b/src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java new file mode 100644 index 0000000000..b54f34566a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/dto/UserProfileResponse.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.domain.user.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record UserProfileResponse( + @Schema(example = "1", description = "사용자 고유 id") + Integer id, + + @Schema(example = "example12", description = "로그인 id") + String loginId, + + @Schema(description = "익명 닉네임", example = "익명_1676688416361") + String anonymousNickname, + + @Schema(description = "이메일 주소", example = "koin123@koreatech.ac.kr") + String email, + + @Schema(description = "성별(남:0, 여:1)", example = "1") + Integer gender, + + @Schema(description = "이름", example = "최준호") + String name, + + @Schema(description = "닉네임", example = "juno") + String nickname, + + @Schema(description = "휴대폰 번호", example = "010-0000-0000") + String phoneNumber, + + @Schema(description = "사용자 타입. 학생·총학생회는 학생 정보가 함께 내려옵니다.", example = "STUDENT") + UserType userType, + + @Schema(description = "학번. 학생·총학생회만 존재합니다.", example = "2029136012", nullable = true) + String studentNumber, + + @Schema(description = "전공(학부). 학생·총학생회만 존재합니다.", example = "컴퓨터공학부", nullable = true) + String major +) { + + public static UserProfileResponse from(User user) { + return of(user, null, null); + } + + public static UserProfileResponse from(Student student) { + String major = student.getDepartment() == null ? null : student.getDepartment().getName(); + return of(student.getUser(), student.getStudentNumber(), major); + } + + private static UserProfileResponse of(User user, String studentNumber, String major) { + return new UserProfileResponse( + user.getId(), + user.getLoginId(), + user.getAnonymousNickname(), + user.getEmail(), + user.getGender() != null ? user.getGender().ordinal() : null, + user.getName(), + user.getNickname(), + user.getPhoneNumber(), + user.getUserType(), + studentNumber, + major + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index 22c0b8c5ce..135e6182f6 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.user.model; +import static in.koreatech.koin.domain.user.model.UserType.KOIN_STUDENT_TYPES; import static lombok.AccessLevel.PROTECTED; import java.time.LocalDateTime; @@ -254,4 +255,8 @@ public String getDisplayNickname() { } return "익명 사용자"; } + + public boolean isKoinStudent() { + return KOIN_STUDENT_TYPES.contains(this.getUserType()); + } } diff --git a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java index 02490922c2..92e0e8c6b3 100644 --- a/src/main/java/in/koreatech/koin/domain/user/service/UserService.java +++ b/src/main/java/in/koreatech/koin/domain/user/service/UserService.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.global.auth.JwtProvider; import in.koreatech.koin.common.event.UserDeleteEvent; import in.koreatech.koin.common.event.UserMarketingAgreementEvent; @@ -28,6 +29,7 @@ import in.koreatech.koin.domain.user.dto.UserRegisterRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordByEmailRequest; import in.koreatech.koin.domain.user.dto.UserResetPasswordBySmsRequest; +import in.koreatech.koin.domain.user.dto.UserProfileResponse; import in.koreatech.koin.domain.user.dto.UserResponse; import in.koreatech.koin.domain.user.dto.UserTypeResponse; import in.koreatech.koin.domain.user.dto.UserUpdatePasswordRequest; @@ -59,6 +61,16 @@ public UserResponse getUser(Integer userId) { return UserResponse.from(user); } + /** 회원 유형과 무관한 내 정보. 학생·총학생회는 학생 정보(학번·전공)를 포함한다. */ + public UserProfileResponse getProfile(Integer userId) { + User user = userRepository.getById(userId); + if (user.isKoinStudent()) { + Student student = studentRepository.getById(userId); + return UserProfileResponse.from(student); + } + return UserProfileResponse.from(user); + } + public UserTypeResponse getUserType(Integer userId) { User user = userRepository.getById(userId); return UserTypeResponse.from(user); diff --git a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java index f507cfbd12..6478b5ceb8 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthApi.java @@ -9,8 +9,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; -import in.koreatech.koin.domain.user.web.dto.WebCsrfTokenResponse; import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.domain.user.web.dto.WebSessionResponse; import in.koreatech.koin.global.auth.WebAuthRequestValidator; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.code.ApiResponseCodes; @@ -94,18 +94,19 @@ ResponseEntity logout( ); @Operation( - summary = "웹 CSRF 토큰 조회", + summary = "웹 세션 조회", description = """ - refresh 쿠키로 현재 웹 세션의 CSRF 토큰을 조회합니다. - 동일한 CSRF 토큰을 일반 쿠키로 다시 발급합니다. - access 토큰과 refresh 토큰은 재발급하지 않습니다. + 현재 브라우저의 웹 로그인 세션 상태를 조회합니다. 비로그인도 오류가 아니라 authenticated=false인 200으로 응답합니다. + refresh 쿠키의 세션이 유효하면 access가 만료되었더라도 authenticated=true입니다(access는 이후 요청에서 재발급). + 세션이 유효하면 CSRF 일반 쿠키를 복구합니다. 토큰은 재발급(회전)하지 않습니다. + 세션이 서버 저장소에서 사라졌거나 쿠키가 형식에 맞지 않으면 access·refresh·CSRF 쿠키를 만료시킵니다. + refresh 값이 세션과 일치하지 않는 경우(다른 탭이 이미 회전)에는 쿠키를 지우지 않습니다. """ ) - @ApiResponse(responseCode = "200", description = "CSRF 토큰 조회 성공", + @ApiResponse(responseCode = "200", description = "세션 조회 성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = WebCsrfTokenResponse.class))) - @ApiResponseCodes({ApiResponseCode.UNAUTHORIZED_USER, ApiResponseCode.FORBIDDEN_WEB_ORIGIN, - ApiResponseCode.INTERNAL_SERVER_ERROR}) - @GetMapping("/csrf") - ResponseEntity getCsrfToken(HttpServletRequest request, HttpServletResponse response); + schema = @Schema(implementation = WebSessionResponse.class))) + @ApiResponseCodes({ApiResponseCode.FORBIDDEN_WEB_ORIGIN, ApiResponseCode.INTERNAL_SERVER_ERROR}) + @GetMapping("/session") + ResponseEntity getSession(HttpServletRequest request, HttpServletResponse response); } diff --git a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java index a2d17ae8d6..dfca8ac774 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/controller/WebAuthController.java @@ -12,11 +12,12 @@ import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.domain.user.web.dto.WebAuthResponse; -import in.koreatech.koin.domain.user.web.dto.WebCsrfTokenResponse; import in.koreatech.koin.domain.user.web.dto.WebLoginRequest; +import in.koreatech.koin.domain.user.web.dto.WebSessionResponse; import in.koreatech.koin.domain.user.web.service.WebAuthService; import in.koreatech.koin.domain.user.web.service.WebAuthTokens; -import in.koreatech.koin.domain.user.web.service.WebCsrfToken; +import in.koreatech.koin.domain.user.web.service.WebSessionResult; +import in.koreatech.koin.domain.user.web.service.WebSessionService; import in.koreatech.koin.global.auth.WebAuthCookieManager; import in.koreatech.koin.global.auth.WebAuthRequestValidator; import jakarta.servlet.http.HttpServletRequest; @@ -30,6 +31,7 @@ public class WebAuthController implements WebAuthApi { private final WebAuthService webAuthService; + private final WebSessionService webSessionService; private final WebAuthCookieManager cookieManager; @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) @@ -64,10 +66,18 @@ public ResponseEntity logout( return ResponseEntity.noContent().build(); } - @GetMapping("/csrf") - public ResponseEntity getCsrfToken(HttpServletRequest request, HttpServletResponse response) { - WebCsrfToken csrfToken = webAuthService.getCsrfToken(cookieManager.getRefreshToken(request)); - cookieManager.writeCsrfToken(response, csrfToken); - return ResponseEntity.ok(new WebCsrfTokenResponse(csrfToken.value())); + @GetMapping("/session") + public ResponseEntity getSession(HttpServletRequest request, HttpServletResponse response) { + WebSessionResult result = webSessionService.getSession( + cookieManager.getRefreshToken(request), cookieManager.getAccessToken(request)); + switch (result.status()) { + case AUTHENTICATED -> { + cookieManager.writeCsrfToken(response, result.csrfToken()); + return ResponseEntity.ok(new WebSessionResponse(true, result.userType(), result.csrfToken().value())); + } + case SESSION_LOST -> cookieManager.clear(response); + case ANONYMOUS -> { } + } + return ResponseEntity.ok(WebSessionResponse.anonymous()); } } diff --git a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java deleted file mode 100644 index 9d5b499fa9..0000000000 --- a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebCsrfTokenResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package in.koreatech.koin.domain.user.web.dto; - -import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; - -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -@JsonNaming(value = SnakeCaseStrategy.class) -public record WebCsrfTokenResponse( - @Schema(description = "CSRF 일반 쿠키와 동일한 세션별 HMAC 토큰. 상태 변경 요청의 X-CSRF-Token 헤더에 전달합니다.", requiredMode = REQUIRED) - String csrfToken -) { - -} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java new file mode 100644 index 0000000000..d60641f4d1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/dto/WebSessionResponse.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.user.web.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record WebSessionResponse( + @Schema(description = "웹 로그인 세션이 유효한지 여부. 비로그인은 오류가 아니라 false로 응답합니다.", example = "true", requiredMode = REQUIRED) + boolean authenticated, + + @Schema(description = "회원 유형. authenticated가 true일 때만 존재합니다.", example = "STUDENT", requiredMode = NOT_REQUIRED, nullable = true) + String userType, + + @Schema(description = "CSRF 일반 쿠키와 동일한 세션별 HMAC 토큰. authenticated가 true일 때만 존재합니다.", requiredMode = NOT_REQUIRED, nullable = true) + String csrfToken +) { + + public static WebSessionResponse anonymous() { + return new WebSessionResponse(false, null, null); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java b/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java index f90a74b33b..1cffe9afe2 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/model/WebAuthSession.java @@ -5,6 +5,8 @@ import java.security.MessageDigest; import java.time.Instant; +import com.fasterxml.jackson.annotation.JsonIgnore; + import in.koreatech.koin.global.auth.exception.AuthenticationException; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.exception.CustomException; @@ -25,14 +27,23 @@ public void requireRefreshToken(WebRefreshToken token) { } } + public boolean matchesRefreshToken(WebRefreshToken token) { + return matches(refreshTokenHash, token.hash()); + } + public void requireCsrfToken(String token) { if (!matches(csrfToken, token)) { throw CustomException.of(ApiResponseCode.INVALID_CSRF_TOKEN); } } + @JsonIgnore + public boolean isExpired() { + return !expiresAt.isAfter(Instant.now()); + } + public void requireNotExpired() { - if (!expiresAt.isAfter(Instant.now())) { + if (isExpired()) { throw AuthenticationException.withDetail("웹 로그인 유지 기간이 만료되었습니다."); } } diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java index b20e362f84..57e1e416ea 100644 --- a/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebAuthService.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -87,26 +88,33 @@ public void logout(String value, String csrfToken) { } } - public WebCsrfToken getCsrfToken(String value) { - WebRefreshToken refreshToken = WebRefreshToken.parse(value); - WebAuthSession session = getSession(refreshToken.sessionId()); - session.requireRefreshToken(refreshToken); - return new WebCsrfToken(session.csrfToken(), session.expiresAt(), session.autoLogin()); + public WebAuthSession authenticate(String accessToken) { + WebAuthSession session = getSessionByAccessToken(accessToken); + // @UserId만 사용하는 API에서도 탈퇴한 계정의 쿠키를 인증하지 않는다. + if (!userRepository.existsById(session.userId())) { + throw AuthenticationException.withDetail("웹 로그인 사용자가 존재하지 않습니다."); + } + return session; } - public WebAuthSession authenticate(String accessToken) { + private WebAuthSession getSessionByAccessToken(String accessToken) { JwtProvider.WebTokenClaims claims = jwtProvider.getWebTokenClaims(accessToken); WebAuthSession session = getSession(claims.sessionId()); if (!session.userId().equals(claims.userId())) { throw AuthenticationException.withDetail("웹 로그인 사용자 정보가 일치하지 않습니다."); } - // @UserId만 사용하는 API에서도 탈퇴한 계정의 쿠키를 인증하지 않는다. - if (!userRepository.existsById(session.userId())) { - throw AuthenticationException.withDetail("웹 로그인 사용자가 존재하지 않습니다."); - } return session; } + /** 사용자 존재 여부까지는 확인하지 않는 access 토큰 → 세션 조회. 유효하지 않으면 비어 있다. */ + public Optional findSessionByAccessToken(String accessToken) { + try { + return Optional.of(getSessionByAccessToken(accessToken)); + } catch (AuthenticationException e) { + return Optional.empty(); + } + } + private WebAuthSession getSession(String sessionId) { WebAuthSession session = sessionRepository.findById(sessionId) .orElseThrow(() -> AuthenticationException.withDetail( diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java new file mode 100644 index 0000000000..98b5d37692 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionResult.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.user.web.service; + +/** + * 웹 세션 조회 결과. 세션의 도메인 상태만 담고, 쿠키를 어떻게 다룰지는 호출한 쪽(컨트롤러)이 정한다. + * + * @param status 세션 상태 + * @param userType AUTHENTICATED일 때의 회원 유형 + * @param csrfToken AUTHENTICATED일 때의 세션별 CSRF 토큰(쿠키 수명 정보 포함) + */ +public record WebSessionResult(Status status, String userType, WebCsrfToken csrfToken) { + + public enum Status { + /** 유효한 세션이다. */ + AUTHENTICATED, + /** 인증 정보가 없거나 현재 판단할 수 없다. 브라우저의 쿠키는 그대로 둔다. */ + ANONYMOUS, + /** 쿠키는 남았지만 서버 세션이 확정적으로 사라졌거나 쿠키 형식이 잘못되었다. 쿠키는 더 쓸모가 없다. */ + SESSION_LOST + } + + static WebSessionResult authenticated(String userType, WebCsrfToken csrfToken) { + return new WebSessionResult(Status.AUTHENTICATED, userType, csrfToken); + } + + static WebSessionResult anonymous() { + return new WebSessionResult(Status.ANONYMOUS, null, null); + } + + static WebSessionResult sessionLost() { + return new WebSessionResult(Status.SESSION_LOST, null, null); + } + + @Override + public String toString() { + return "WebSessionResult[REDACTED]"; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java new file mode 100644 index 0000000000..d5765967a8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/web/service/WebSessionService.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.user.web.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.domain.user.web.repository.WebAuthSessionRedisRepository; +import in.koreatech.koin.global.auth.exception.AuthenticationException; +import lombok.RequiredArgsConstructor; + +/** + * 웹 로그인 세션 상태를 조회하는 읽기 전용 서비스. 토큰을 회전하지 않고 세션도 변경하지 않는다. + * SQL 트랜잭션 안에서 Redis를 기다리지 않도록 트랜잭션을 열지 않는다. + */ +@Service +@RequiredArgsConstructor +public class WebSessionService { + + private final WebAuthService webAuthService; + private final WebAuthSessionRedisRepository sessionRepository; + private final UserRepository userRepository; + + /** + * refresh 쿠키의 세션을 우선 보고, 일치하지 않으면 access 쿠키로 확인한다. refresh만 남은 상태(access 만료)도 + * 세션이 유효하므로 AUTHENTICATED이며, access는 이후 API 요청의 401에서 재발급된다. + * SESSION_LOST는 세션이 저장소에서 확정적으로 사라졌거나 쿠키가 형식에 맞지 않을 때뿐이다. + * refresh 값이 세션과 어긋나는 경우는 다른 탭이 이미 회전시켰을 수 있어 ANONYMOUS로 남긴다. + */ + public WebSessionResult getSession(String refreshValue, String accessValue) { + RefreshLookup refresh = lookupByRefreshToken(refreshValue); + WebAuthSession session = refresh.session(); + if (session == null && StringUtils.hasText(accessValue)) { + session = webAuthService.findSessionByAccessToken(accessValue).orElse(null); + } + if (session == null) { + return hasCredentials(refreshValue, accessValue) && !refresh.rotated() + ? WebSessionResult.sessionLost() : WebSessionResult.anonymous(); + } + User user = userRepository.findById(session.userId()).orElse(null); + if (user == null) { + return WebSessionResult.sessionLost(); + } + return WebSessionResult.authenticated(user.getUserType().getValue(), + new WebCsrfToken(session.csrfToken(), session.expiresAt(), session.autoLogin())); + } + + private boolean hasCredentials(String refreshValue, String accessValue) { + return StringUtils.hasText(refreshValue) || StringUtils.hasText(accessValue); + } + + private RefreshLookup lookupByRefreshToken(String value) { + if (!StringUtils.hasText(value)) { + return RefreshLookup.NONE; + } + try { + WebRefreshToken refreshToken = WebRefreshToken.parse(value); + Optional found = sessionRepository.findById(refreshToken.sessionId()); + if (found.isEmpty() || found.get().isExpired()) { + return RefreshLookup.NONE; + } + return found.get().matchesRefreshToken(refreshToken) ? new RefreshLookup(found.get(), false) + : new RefreshLookup(null, true); + } catch (AuthenticationException e) { + return RefreshLookup.NONE; + } + } + + /** rotated: 세션은 있지만 refresh 값이 다르다(다른 탭이 이미 회전시켰을 수 있다). */ + private record RefreshLookup(WebAuthSession session, boolean rotated) { + + static final RefreshLookup NONE = new RefreshLookup(null, false); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java b/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java index cb47cb5a3e..48228d8999 100644 --- a/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/config/WebAuthOpenApiCustomizer.java @@ -43,8 +43,8 @@ public void customise(OpenAPI openApi) { List.of(cookieSecurity()), "201"); configureOperation(openApi, "logout", PathItem.HttpMethod.POST, List.of(cookieSecurity(), new SecurityRequirement()), "204"); - configureOperation(openApi, "csrf", PathItem.HttpMethod.GET, - List.of(cookieSecurity()), "200"); + configureOperation(openApi, "session", PathItem.HttpMethod.GET, + List.of(cookieSecurity(), new SecurityRequirement()), "200"); } private void configureOperation(OpenAPI openApi, String endpoint, PathItem.HttpMethod method, @@ -117,7 +117,7 @@ private Header cookieHeader(boolean clear, boolean csrfOnly) { String description = clear ? "access·refresh·CSRF 쿠키 만료 (Max-Age=0)" : "access·refresh는 HttpOnly, CSRF는 일반 쿠키로 발급합니다. auto_login=true이면 Max-Age를 설정합니다."; String csrf = cookie(properties.csrfCookieName(), clear ? "" : "SIGNED_CSRF_TOKEN_PLACEHOLDER", "/", clear, false); - return new Header().description((csrfOnly ? "기존 세션의 CSRF 쿠키만 복구합니다." : description) + return new Header().description((csrfOnly ? "세션이 유효하면 CSRF 쿠키만 복구합니다(토큰은 회전하지 않음). 세션이 사라졌으면 세 쿠키를 만료시킵니다." : description) + " refresh는 API 호스트 전용이며 access·CSRF의 Domain과 쿠키 이름·Secure·SameSite는 서버 설정에 따릅니다.") .schema(new ArraySchema().items(new StringSchema())) .example(csrfOnly ? List.of(csrf) : List.of( diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java index 62ba490499..ce2ed69383 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/StudentApiTest.java @@ -113,6 +113,33 @@ void setup() { """)); } + @Test + void 학생은_회원_유형과_무관한_내_정보_API로_학번과_전공까지_조회한다() throws Exception { + Department department = departmentFixture.컴퓨터공학부(); + Student student = userFixture.준호_학생(department, null); + String token = userFixture.getToken(student.getUser()); + + mockMvc.perform( + get("/v3/users/me") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "anonymous_nickname": "익명_주노", + "email": "juno@koreatech.ac.kr", + "gender": 0, + "major": "컴퓨터공학부", + "name": "테스트용_준호", + "nickname": "준호", + "phone_number": "01012345678", + "student_number": "2019136135", + "user_type": "STUDENT" + } + """)); + } + @Test void 올바른_학생계정인지_확인한다_토큰_정보가_올바르지_않으면_401() throws Exception { Department department = departmentFixture.컴퓨터공학부(); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java index 9932ec9ddd..f33f936013 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthApiTest.java @@ -149,11 +149,13 @@ void setUp() { @ParameterizedTest @ValueSource(booleans = {true, false}) - void csrf_조회는_기존_세션의_쿠키만_복구하고_refresh를_회전하지_않는다(boolean autoLogin) throws Exception { + void 세션_조회는_기존_세션의_CSRF_쿠키만_복구하고_refresh를_회전하지_않는다(boolean autoLogin) throws Exception { WebLogin login = login(autoLogin); - MvcResult result = mockMvc.perform(get(AUTH_PATH + "/csrf").header("Origin", ORIGIN).cookie(login.refresh())) - .andExpect(status().isOk()).andExpect(jsonPath("$.csrf_token").value(login.csrfToken())).andReturn(); + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN).cookie(login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())).andReturn(); assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(1); Cookie restored = result.getResponse().getCookie(properties.csrfCookieName()); @@ -272,12 +274,13 @@ void setUp() { } @Test - void 만료된_access_쿠키가_있어도_csrf_조회와_재발급이_가능하다() throws Exception { + void 만료된_access_쿠키가_있어도_세션_조회와_재발급이_가능하다() throws Exception { WebLogin login = login(true); Cookie expired = expiredAccess(login); - mockMvc.perform(get(AUTH_PATH + "/csrf").header("Origin", ORIGIN).cookie(expired, login.refresh())) + mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN).cookie(expired, login.refresh())) .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())) .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")); @@ -485,6 +488,116 @@ void setUp() { .andExpect(header().string("Access-Control-Allow-Credentials", "true")); } + @Test + void 쿠키가_없으면_세션_조회는_오류가_아니라_비로그인으로_응답한다() throws Exception { + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andExpect(jsonPath("$.user_type").doesNotExist()) + .andExpect(jsonPath("$.csrf_token").doesNotExist()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).isEmpty(); + } + + @Test + void 세션_조회는_유효한_세션이면_회원_유형을_주고_CSRF_쿠키만_복구한다() throws Exception { + WebLogin login = login(true); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(login.access(), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andExpect(jsonPath("$.csrf_token").value(login.csrfToken())) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(1); + assertThat(result.getResponse().getCookie(properties.csrfCookieName()).getValue()).isEqualTo(login.csrfToken()); + refresh(login).andExpect(status().isCreated()); + } + + @Test + void access가_만료되어도_refresh_세션이_유효하면_로그인으로_응답하고_회전하지_않는다() throws Exception { + WebLogin login = login(true); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(expiredAccess(login), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(1); + assertThat(result.getResponse().getCookie(properties.refreshCookieName())).isNull(); + refresh(login).andExpect(status().isCreated()); + } + + @Test + void 세션이_사라졌으면_비로그인으로_응답하고_인증_쿠키를_모두_만료시킨다() throws Exception { + WebLogin login = login(true); + logout(login).andExpect(status().isNoContent()); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(login.access(), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(3); + assertThat(result.getResponse().getCookie(properties.accessCookieName()).getMaxAge()).isZero(); + assertThat(result.getResponse().getCookie(properties.refreshCookieName()).getMaxAge()).isZero(); + assertThat(result.getResponse().getCookie(properties.csrfCookieName()).getMaxAge()).isZero(); + } + + @Test + void 형식이_잘못된_refresh_쿠키는_비로그인으로_응답하고_쿠키를_만료시킨다() throws Exception { + Cookie broken = new Cookie(properties.refreshCookieName(), "not-a-refresh-token"); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN).cookie(broken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).hasSize(3); + } + + @Test + void 다른_탭이_이미_회전시킨_이전_refresh는_비로그인으로_보지만_쿠키를_지우지_않는다() throws Exception { + WebLogin login = login(true); + refresh(login).andExpect(status().isCreated()); + + MvcResult result = mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", ORIGIN) + .cookie(expiredAccess(login), login.refresh())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andReturn(); + + assertThat(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE)).isEmpty(); + } + + @Test + void 세션_조회도_허용되지_않은_출처는_거부한다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get(AUTH_PATH + "/session").header("Origin", "https://evil.example").cookie(login.refresh())) + .andExpect(status().isForbidden()); + } + + @Test + void 일반_회원은_회원_유형과_무관한_내_정보_API로_조회한다() throws Exception { + WebLogin login = login(true); + + mockMvc.perform(get("/v3/users/me").header("Origin", ORIGIN).cookie(login.access())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user_type").value("GENERAL")) + .andExpect(jsonPath("$.login_id").value("web-test")) + .andExpect(jsonPath("$.anonymous_nickname").exists()) + .andExpect(jsonPath("$.student_number").doesNotExist()) + .andExpect(jsonPath("$.major").doesNotExist()); + } + private WebLogin login(boolean autoLogin) throws Exception { MvcResult result = mockMvc.perform(post(AUTH_PATH + "/login").header("Origin", ORIGIN) .header("User-Agent", MOBILE_USER_AGENT) diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java index 34e006144b..3c400e443e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthHttpTest.java @@ -80,7 +80,7 @@ class WebAuthHttpTest extends AcceptanceTest { assertThat(missingCsrf.statusCode()).isEqualTo(403); assertThat(missingCsrf.headers().allValues("Set-Cookie")).isEmpty(); - HttpResponse restored = send(request(AUTH_PATH + "/csrf").header("Cookie", refresh).GET()); + HttpResponse restored = send(request(AUTH_PATH + "/session").header("Cookie", refresh).GET()); assertThat(restored.statusCode()).isEqualTo(200); assertThat(restored.headers().allValues("Set-Cookie")).hasSize(1); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java index ef4c8b41d7..03ec25639b 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WebAuthOpenApiContractTest.java @@ -33,7 +33,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { JsonNode openApi = openApi(LOGIN_GROUP); JsonNode nativeLogin = openApi.at("/paths/~1v2~1users~1login/post"); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { assertThat(operation(openApi, endpoint).path("tags")).isEqualTo(nativeLogin.path("tags")); } assertThat(nativeLogin.path("tags").get(0).asText()).isEqualTo("(Normal) User: 유저"); @@ -45,7 +45,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { void 웹_인증_API는_회원_그룹에_중복_노출하지_않는다() throws Exception { JsonNode openApi = openApi("4. User API"); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { assertThat(operation(openApi, endpoint).isMissingNode()).isTrue(); } assertThat(openApi.at("/paths/~1v2~1users~1login/post").isMissingNode()).isFalse(); @@ -57,7 +57,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { void 웹_인증_출처와_refresh_쿠키를_명세한다() throws Exception { JsonNode openApi = openApi(LOGIN_GROUP); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { JsonNode operation = operation(openApi, endpoint); assertThat(parameter(operation, "Origin").path("in").asText()).isEqualTo("header"); assertThat(parameter(operation, "Referer").path("in").asText()).isEqualTo("header"); @@ -65,7 +65,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { JsonNode cookieScheme = openApi.path("components").path("securitySchemes").path(WEB_REFRESH_COOKIE); assertThat(cookieScheme.path("in").asText()).isEqualTo("cookie"); assertThat(cookieScheme.path("name").asText()).isEqualTo("__Secure-koin-web-refresh"); - for (String endpoint : List.of("refresh", "csrf")) { + for (String endpoint : List.of("refresh", "session")) { assertThat(operation(openApi, endpoint).path("security").get(0).has(WEB_REFRESH_COOKIE)).isTrue(); } } @@ -81,7 +81,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { assertThat(logout.path("security")).hasSize(2); assertThat(logout.path("security").get(1)).isEmpty(); assertThat(logout.path("description").asText()).contains("서버 세션은 삭제하지 않습니다"); - assertThat(operation(openApi, "csrf").path("parameters")) + assertThat(operation(openApi, "session").path("parameters")) .noneMatch(parameter -> parameter.path("name").asText().equals("X-CSRF-Token")); } @@ -111,7 +111,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { assertThat(properties).hasSize(2); assertThat(properties.has("user_type")).isTrue(); assertThat(properties.has("csrf_token")).isTrue(); - assertThat(operation(openApi, "csrf").at("/responses/200/headers/Set-Cookie/example")).hasSize(1); + assertThat(operation(openApi, "session").at("/responses/200/headers/Set-Cookie/example")).hasSize(1); } @Test @@ -127,7 +127,7 @@ class WebAuthOpenApiContractTest extends AcceptanceTest { .isTrue(); assertThat(login.at("/responses/415/content/application~1json/schema/$ref").asText()) .endsWith("ErrorResponse"); - for (String endpoint : List.of("login", "refresh", "logout", "csrf")) { + for (String endpoint : List.of("login", "refresh", "logout", "session")) { assertThat(operation(openApi, endpoint).at("/responses/500/content/application~1json/examples") .has("INTERNAL_SERVER_ERROR")).isTrue(); assertThat(operation(openApi, endpoint).at("/responses/403/content/text~1plain/example").asText()) @@ -147,7 +147,7 @@ private JsonNode openApi(String group) throws Exception { } private JsonNode operation(JsonNode openApi, String endpoint) { - String method = endpoint.equals("csrf") ? "get" : "post"; + String method = endpoint.equals("session") ? "get" : "post"; return openApi.path("paths").path("/v2/web/auth/" + endpoint).path(method); } diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java index 8610397ba3..208fd103d9 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthControllerFailureTest.java @@ -27,6 +27,7 @@ import in.koreatech.koin.domain.user.web.controller.WebAuthController; import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.domain.user.web.service.WebSessionService; import in.koreatech.koin.global.auth.WebAuthCookieManager; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.config.WebAuthProperties; @@ -40,31 +41,34 @@ class WebAuthControllerFailureTest { @Mock private WebAuthService service; + @Mock + private WebSessionService sessionService; + private MockMvc mockMvc; private final WebAuthProperties properties = new WebAuthProperties( Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax", null, "koin-web"); @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(new WebAuthController(service, new WebAuthCookieManager(properties))) + mockMvc = MockMvcBuilders.standaloneSetup(new WebAuthController(service, sessionService, new WebAuthCookieManager(properties))) .setControllerAdvice(new GlobalExceptionHandler()) .build(); } @ParameterizedTest - @ValueSource(strings = {"login", "refresh", "logout", "csrf"}) + @ValueSource(strings = {"login", "refresh", "logout", "session"}) void Redis_장애는_서버_오류를_반환하고_기존_쿠키를_덮거나_삭제하지_않는다(String endpoint) throws Exception { RedisConnectionFailureException failure = new RedisConnectionFailureException("테스트용 Redis 연결 실패"); switch (endpoint) { case "login" -> when(service.login(any())).thenThrow(failure); case "refresh" -> when(service.refresh(anyString(), anyString())).thenThrow(failure); case "logout" -> doThrow(failure).when(service).logout(anyString(), anyString()); - case "csrf" -> when(service.getCsrfToken(anyString())).thenThrow(failure); + case "session" -> when(sessionService.getSession(any(), any())).thenThrow(failure); default -> throw new IllegalArgumentException("잘못된 테스트 경로"); } - MockHttpServletRequestBuilder request = "csrf".equals(endpoint) - ? get("/v2/web/auth/csrf") : post("/v2/web/auth/" + endpoint); + MockHttpServletRequestBuilder request = "session".equals(endpoint) + ? get("/v2/web/auth/session") : post("/v2/web/auth/" + endpoint); mockMvc.perform(request.header("Origin", "http://localhost:3000").header("X-CSRF-Token", "test-csrf") .cookie(new Cookie(properties.refreshCookieName(), "test-refresh")) .contentType(MediaType.APPLICATION_JSON).content("{\"login_id\":\"test\",\"login_pw\":\"test-password\"}")) diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java index 69329d394c..b830be23af 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebAuthFailureTest.java @@ -86,17 +86,6 @@ void setUp() { verifyNoSessionMutation(); } - @Test - void csrf_조회_중_Redis_조회가_실패하면_토큰을_반환하지_않는다() { - RedisConnectionFailureException failure = redisFailure(); - when(sessionRepository.findById(session.id())).thenThrow(failure); - - assertThatThrownBy(() -> service.getCsrfToken(token.value())).isSameAs(failure); - - verifyNoInteractions(userRepository, userService); - verifyNoSessionMutation(); - } - @Test void 로그아웃_중_Redis_조회가_실패하면_성공으로_처리하지_않는다() { RedisConnectionFailureException failure = redisFailure(); @@ -197,16 +186,6 @@ void setUp() { verifyNoSessionMutation(); } - @Test - void 만료된_세션으로는_csrf_토큰을_조회할_수_없다() { - when(sessionRepository.findById(session.id())).thenReturn(Optional.of(expiredSession())); - - assertThatThrownBy(() -> service.getCsrfToken(token.value())).isInstanceOf(AuthenticationException.class); - - verifyNoInteractions(userRepository, userService); - verifyNoSessionMutation(); - } - @Test void 재발급_시_사용자가_없으면_세션을_회전하거나_다시_저장하지_않는다() { when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); diff --git a/src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java new file mode 100644 index 0000000000..3f47b5a1b1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/user/web/WebSessionServiceTest.java @@ -0,0 +1,170 @@ +package in.koreatech.koin.unit.domain.user.web; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.RedisConnectionFailureException; + +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.web.model.WebAuthSession; +import in.koreatech.koin.domain.user.web.model.WebRefreshToken; +import in.koreatech.koin.domain.user.web.repository.WebAuthSessionRedisRepository; +import in.koreatech.koin.domain.user.web.service.WebAuthService; +import in.koreatech.koin.domain.user.web.service.WebSessionResult; +import in.koreatech.koin.domain.user.web.service.WebSessionResult.Status; +import in.koreatech.koin.domain.user.web.service.WebSessionService; +import in.koreatech.koin.global.auth.WebCsrfTokenProvider; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class WebSessionServiceTest { + + @Mock + private WebAuthService webAuthService; + + @Mock + private WebAuthSessionRedisRepository sessionRepository; + + @Mock + private UserRepository userRepository; + + private final User user = UserFixture.id_설정_코인_유저(1); + private final WebCsrfTokenProvider csrfTokenProvider = new WebCsrfTokenProvider("csrf-test-signing-key-at-least-32-bytes"); + private WebSessionService service; + private WebRefreshToken token; + private WebAuthSession session; + + @BeforeEach + void setUp() { + service = new WebSessionService(webAuthService, sessionRepository, userRepository); + token = WebRefreshToken.create(); + session = new WebAuthSession(token.sessionId(), user.getId(), token.hash(), + csrfTokenProvider.createToken(token.sessionId()), WebRefreshToken.hash(user.getLoginPw()), + Instant.now().plusSeconds(3600), true); + } + + @Test + void 쿠키가_없으면_저장소를_조회하지_않고_익명이다() { + WebSessionResult result = service.getSession(null, null); + + assertThat(result.status()).isEqualTo(Status.ANONYMOUS); + verifyNoInteractions(webAuthService, sessionRepository, userRepository); + } + + @Test + void 유효한_refresh_세션이면_회원_유형과_CSRF를_돌려주고_세션을_변경하지_않는다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + WebSessionResult result = service.getSession(token.value(), null); + + assertThat(result.status()).isEqualTo(Status.AUTHENTICATED); + assertThat(result.userType()).isEqualTo(user.getUserType().getValue()); + assertThat(result.csrfToken().value()).isEqualTo(session.csrfToken()); + verifyNoSessionMutation(); + } + + @Test + void access가_만료되어도_refresh_세션이_유효하면_인증으로_본다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + WebSessionResult result = service.getSession(token.value(), "expired-access"); + + assertThat(result.status()).isEqualTo(Status.AUTHENTICATED); + verifyNoInteractions(webAuthService); + } + + @Test + void refresh가_없어도_유효한_access_세션이면_인증으로_본다() { + when(webAuthService.findSessionByAccessToken("valid-access")).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + WebSessionResult result = service.getSession(null, "valid-access"); + + assertThat(result.status()).isEqualTo(Status.AUTHENTICATED); + } + + @Test + void 저장소에_세션이_없으면_세션_소멸이다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.empty()); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.SESSION_LOST); + verifyNoSessionMutation(); + } + + @Test + void 만료된_세션은_세션_소멸이다() { + WebAuthSession expired = new WebAuthSession(session.id(), session.userId(), token.hash(), session.csrfToken(), + session.credentialHash(), Instant.now().minusSeconds(1), session.autoLogin()); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(expired)); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.SESSION_LOST); + } + + @Test + void 형식이_잘못된_refresh_쿠키는_저장소를_조회하지_않고_세션_소멸이다() { + WebSessionResult result = service.getSession("not-a-refresh-token", null); + + assertThat(result.status()).isEqualTo(Status.SESSION_LOST); + verifyNoInteractions(sessionRepository); + } + + @Test + void 다른_탭이_회전시킨_이전_refresh는_익명으로_두고_쿠키를_버리게_하지_않는다() { + WebAuthSession rotated = session.rotate(token.rotate()); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(rotated)); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.ANONYMOUS); + verifyNoSessionMutation(); + } + + @Test + void 회전된_refresh여도_access가_유효하면_인증으로_본다() { + WebAuthSession rotated = session.rotate(token.rotate()); + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(rotated)); + when(webAuthService.findSessionByAccessToken("valid-access")).thenReturn(Optional.of(rotated)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + assertThat(service.getSession(token.value(), "valid-access").status()).isEqualTo(Status.AUTHENTICATED); + } + + @Test + void 탈퇴한_계정의_세션은_세션_소멸이다() { + when(sessionRepository.findById(session.id())).thenReturn(Optional.of(session)); + when(userRepository.findById(user.getId())).thenReturn(Optional.empty()); + + assertThat(service.getSession(token.value(), null).status()).isEqualTo(Status.SESSION_LOST); + } + + @Test + void Redis_조회가_실패하면_익명으로_단정하지_않고_오류를_전달한다() { + RedisConnectionFailureException failure = new RedisConnectionFailureException("테스트용 Redis 연결 실패"); + when(sessionRepository.findById(session.id())).thenThrow(failure); + + assertThatThrownBy(() -> service.getSession(token.value(), null)).isSameAs(failure); + + verifyNoInteractions(userRepository); + verifyNoSessionMutation(); + } + + private void verifyNoSessionMutation() { + verify(sessionRepository, never()).save(any()); + verify(sessionRepository, never()).rotate(any(), any()); + verify(sessionRepository, never()).delete(any()); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java b/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java index 5bf13b188a..77d146236a 100644 --- a/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java +++ b/src/test/java/in/koreatech/koin/unit/global/config/WebAuthOpenApiCustomizerTest.java @@ -21,14 +21,14 @@ class WebAuthOpenApiCustomizerTest { @Test - void 공유_쿠키_설정과_CSRF_조회_복구를_문서에_반영한다() { + void 공유_쿠키_설정과_세션_조회의_CSRF_복구를_문서에_반영한다() { WebAuthProperties properties = new WebAuthProperties(Duration.ofMinutes(15), Duration.ofDays(90), true, "Lax", "example.test", "koin-stage-web"); Operation login = operation(); Operation csrf = new Operation().responses(new ApiResponses().addApiResponse("200", new ApiResponse())); OpenAPI openApi = new OpenAPI().paths(new Paths() .addPathItem("/v2/web/auth/login", new PathItem().post(login)) - .addPathItem("/v2/web/auth/csrf", new PathItem().get(csrf))); + .addPathItem("/v2/web/auth/session", new PathItem().get(csrf))); new WebAuthOpenApiCustomizer(properties).customise(openApi); From 304d3d156839977404b781841ae246b079cd6b08 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: Mon, 5 Oct 2026 18:23:34 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=EC=9E=90=EC=9D=98=201=EB=8C=801?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=20=EC=A7=84=EC=9E=85=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#2473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 팀원 모집 지원자의 1대1 채팅 진입 권한 수정 (#2472) * test: 팀 단체 채팅 검증 추가 및 HTTP 테스트 JVM 분리 (#2472) * revert: 추가 단체 채팅 테스트와 HTTP 테스트 JVM 분리 제거 (#2472) * test: 팀원 모집 HTTP 테스트 제거 (#2472) --- .../controller/TeamRecruitmentChatApi.java | 3 +- .../service/TeamRecruitmentChatService.java | 11 +-- ...mRecruitmentDirectChatConcurrencyTest.java | 23 +++++-- .../TeamRecruitmentChatServiceTest.java | 68 ++++++++++++++++++- 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java index 1d397a640c..25442c6824 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java @@ -74,7 +74,8 @@ ResponseEntity getChatRoom( }) @Operation( summary = "지원자와 개인 채팅방 생성 또는 조회", - description = "ACCEPTED 지원서만 대상입니다. 기존 DIRECT 채팅방이 있으면 모집 상태와 관계없이 기존 방을 반환합니다. " + description = "모집글 작성자 또는 해당 지원자만 호출할 수 있으며 ACCEPTED 지원서만 대상입니다. " + + "기존 DIRECT 채팅방이 있으면 모집 상태와 관계없이 기존 방을 반환합니다. " + "기존 방이 없을 때는 마감일이 지나지 않은 RECRUITING 상태이거나 " + "정원 충족으로 마감되어 ACTIVE 상태인 TEAM 채팅방이 있는 경우에만 새 방을 생성합니다." ) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index 7861590dfc..fff5b860eb 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -176,7 +176,10 @@ public DirectChatRoomCreationResult getOrCreateDirectChatRoom(Integer userId, In throw CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_FOUND); } - if (!userId.equals(recruitment.getAuthor().getId())) { + User author = recruitment.getAuthor(); + User applicant = application.getApplicant(); + boolean isAuthor = userId.equals(author.getId()); + if (!isAuthor && !userId.equals(applicant.getId())) { throw CustomException.of(TEAM_RECRUITMENT_FORBIDDEN); } @@ -184,7 +187,7 @@ public DirectChatRoomCreationResult getOrCreateDirectChatRoom(Integer userId, In throw CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_ACCEPTED); } - User counterpartUser = application.getApplicant(); + User counterpartUser = isAuthor ? applicant : author; Optional existingDirectChatRoom = chatRoomRepository .findByRecruitment_IdAndApplication_IdAndRoomType( recruitmentId, applicationId, TeamRecruitmentChatRoomType.DIRECT); @@ -212,9 +215,9 @@ public DirectChatRoomCreationResult getOrCreateDirectChatRoom(Integer userId, In chatRoom = chatRoomRepository.save(chatRoom); memberRepository.save(TeamRecruitmentChatMember.builder() - .chatRoom(chatRoom).user(recruitment.getAuthor()).build()); + .chatRoom(chatRoom).user(author).build()); memberRepository.save(TeamRecruitmentChatMember.builder() - .chatRoom(chatRoom).user(counterpartUser).build()); + .chatRoom(chatRoom).user(applicant).build()); return new DirectChatRoomCreationResult(DirectChatRoomResponse.of(chatRoom, counterpartUser), true); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java index 02f235c5a8..edbfe5b606 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java @@ -117,21 +117,34 @@ void setUp() { assertConcurrentGetOrCreate(CONCURRENT_REQUEST_COUNT); } + @Test + void 모집자와_지원자가_동시에_DIRECT를_생성해도_하나의_방과_두_멤버만_저장된다() throws Exception { + assertConcurrentGetOrCreate(CONCURRENT_REQUEST_COUNT, true); + } + private void assertConcurrentGetOrCreate(int requestCount) throws Exception { + assertConcurrentGetOrCreate(requestCount, false); + } + + private void assertConcurrentGetOrCreate(int requestCount, boolean includeApplicant) throws Exception { directRoomSaveBarrier.expectInsertions(requestCount); entityManager.flush(); TestTransaction.flagForCommit(); TestTransaction.end(); - List results = performConcurrentRequests(requestCount); + List results = performConcurrentRequests(requestCount, includeApplicant); List statuses = results.stream() .map(result -> result.getResponse().getStatus()) .toList(); List chatRoomIds = new ArrayList<>(); - for (MvcResult result : results) { + for (int i = 0; i < results.size(); i++) { + MvcResult result = results.get(i); JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString()); chatRoomIds.add(response.path("chat_room_id").asInt()); + Integer expectedCounterpartId = includeApplicant && i % 2 == 1 + ? recruitment.getAuthor().getId() : application.getApplicant().getId(); + assertThat(response.path("counterpart").path("id").asInt()).isEqualTo(expectedCounterpartId); } assertThat(statuses).containsOnly(200, 201); @@ -155,13 +168,15 @@ SELECT COUNT(*) assertThat(memberCount).isEqualTo(2); } - private List performConcurrentRequests(int requestCount) throws Exception { + private List performConcurrentRequests(int requestCount, boolean includeApplicant) throws Exception { + String applicantToken = userFixture.getToken(application.getApplicant()); ExecutorService executor = Executors.newFixedThreadPool(requestCount); CountDownLatch ready = new CountDownLatch(requestCount); CountDownLatch start = new CountDownLatch(1); try { List> futures = new ArrayList<>(); for (int i = 0; i < requestCount; i++) { + String requestToken = includeApplicant && i % 2 == 1 ? applicantToken : authorToken; futures.add(executor.submit(() -> { ready.countDown(); if (!start.await(5, SECONDS)) { @@ -170,7 +185,7 @@ private List performConcurrentRequests(int requestCount) throws Excep return mockMvc.perform(post( "/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", recruitment.getId(), application.getId()) - .header("Authorization", "Bearer " + authorToken)) + .header("Authorization", "Bearer " + requestToken)) .andReturn(); })); } diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index 08637adc24..2da7c2d621 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -357,7 +358,7 @@ void setUpClock() { } @Test - void 모집글_작성자가_아니면_403을_반환한다() { + void 모집글_작성자나_해당_지원자가_아니면_403을_반환한다() { User otherAuthor = UserFixture.id_설정_코인_유저(OTHER_USER_ID); TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); TeamRecruitment recruitment = mock(TeamRecruitment.class); @@ -367,6 +368,7 @@ void setUpClock() { when(application.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(recruitment.getAuthor()).thenReturn(otherAuthor); + when(application.getApplicant()).thenReturn(UserFixture.id_설정_코인_유저(3)); assertThatThrownBy(() -> chatService.getOrCreateDirectChatRoom(USER_ID, RECRUITMENT_ID, APPLICATION_ID)) .isInstanceOf(CustomException.class) @@ -374,6 +376,70 @@ void setUpClock() { .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_FORBIDDEN)); } + @Test + void 승인된_지원자가_DIRECT를_생성하면_모집자가_상대방이고_양쪽이_멤버로_저장된다() { + User author = UserFixture.id_설정_코인_유저(USER_ID); + User applicant = UserFixture.id_설정_코인_유저(OTHER_USER_ID); + TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatRoom savedRoom = mock(TeamRecruitmentChatRoom.class); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); + when(application.getRecruitment()).thenReturn(recruitment); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(recruitment.getAuthor()).thenReturn(author); + when(application.getApplicant()).thenReturn(applicant); + when(application.getStatus()).thenReturn(TeamRecruitmentApplicationStatus.ACCEPTED); + when(recruitment.isRecruiting()).thenReturn(true); + when(chatRoomRepository.save(any(TeamRecruitmentChatRoom.class))).thenReturn(savedRoom); + when(savedRoom.getId()).thenReturn(CHAT_ROOM_ID); + when(savedRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); + + DirectChatRoomCreationResult result = chatService.getOrCreateDirectChatRoom( + OTHER_USER_ID, RECRUITMENT_ID, APPLICATION_ID); + + assertThat(result.isNew()).isTrue(); + assertThat(result.response().counterpart().id()).isEqualTo(USER_ID); + assertThat(result.response().roomName()).isEqualTo(author.getDisplayNickname()); + ArgumentCaptor members = ArgumentCaptor.forClass(TeamRecruitmentChatMember.class); + verify(memberRepository, times(2)).save(members.capture()); + assertThat(members.getAllValues()) + .extracting(member -> member.getUser().getId()) + .containsExactlyInAnyOrder(USER_ID, OTHER_USER_ID); + } + + @Test + void 승인된_지원자가_기존_DIRECT를_조회하면_모집자가_상대방이고_멤버를_추가하지_않는다() { + User author = UserFixture.id_설정_코인_유저(USER_ID); + User applicant = UserFixture.id_설정_코인_유저(OTHER_USER_ID); + TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatRoom existingRoom = mock(TeamRecruitmentChatRoom.class); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); + when(application.getRecruitment()).thenReturn(recruitment); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(recruitment.getAuthor()).thenReturn(author); + when(application.getApplicant()).thenReturn(applicant); + when(application.getStatus()).thenReturn(TeamRecruitmentApplicationStatus.ACCEPTED); + when(chatRoomRepository.findByRecruitment_IdAndApplication_IdAndRoomType( + RECRUITMENT_ID, APPLICATION_ID, TeamRecruitmentChatRoomType.DIRECT)) + .thenReturn(Optional.of(existingRoom)); + when(existingRoom.getId()).thenReturn(CHAT_ROOM_ID); + when(existingRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); + + DirectChatRoomCreationResult result = chatService.getOrCreateDirectChatRoom( + OTHER_USER_ID, RECRUITMENT_ID, APPLICATION_ID); + + assertThat(result.isNew()).isFalse(); + assertThat(result.response().chatRoomId()).isEqualTo(CHAT_ROOM_ID); + assertThat(result.response().counterpart().id()).isEqualTo(USER_ID); + assertThat(result.response().roomName()).isEqualTo(author.getDisplayNickname()); + verifyNoInteractions(memberRepository); + } + @Test void 이미_존재하는_DIRECT_채팅방은_새로_생성하지_않고_기존_채팅방을_반환한다() { User author = UserFixture.id_설정_코인_유저(USER_ID); From e245c8ef0fe997b95dee1ff70b88a7ed83ed1318 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Tue, 6 Oct 2026 21:08:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EA=B5=90=EC=88=98=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=95=20=EA=B0=95=EC=9D=98=EB=A5=BC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=ED=91=9C=EC=97=90=20=EC=B6=94=EA=B0=80=ED=95=A0=20=EB=95=8C=20?= =?UTF-8?q?NullPointerException=EC=9D=B4=20=EB=B0=9C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TakeAllTimetableLectureResponse.java | 3 +- .../response/TimetableLectureResponseV3.java | 3 +- .../domain/TimetableLectureV3ApiTest.java | 23 +++++ .../fixture/LectureAcceptanceFixture.java | 20 ++++ ...TimetableLectureProfessorResponseTest.java | 94 +++++++++++++++++++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/unit/domain/timetableV3/dto/response/TimetableLectureProfessorResponseTest.java diff --git a/src/main/java/in/koreatech/koin/domain/timetableV3/dto/response/TakeAllTimetableLectureResponse.java b/src/main/java/in/koreatech/koin/domain/timetableV3/dto/response/TakeAllTimetableLectureResponse.java index 98ad0574c0..412d84a01c 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV3/dto/response/TakeAllTimetableLectureResponse.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV3/dto/response/TakeAllTimetableLectureResponse.java @@ -110,7 +110,8 @@ public static List from(List lecture.getName(), lecture.getLectureClass(), lecture.getTarget(), - firstNonNull(timetableLecture.getProfessor(),lecture.getProfessor()), + timetableLecture.getProfessor() != null + ? timetableLecture.getProfessor() : lecture.getProfessor(), lecture.getDepartment(), getCourseType(timetableLecture), getGeneralEducationArea(timetableLecture) diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TimetableLectureV3ApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TimetableLectureV3ApiTest.java index 993636b8af..85a1945046 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TimetableLectureV3ApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TimetableLectureV3ApiTest.java @@ -1,7 +1,9 @@ package in.koreatech.koin.acceptance.domain; +import static org.hamcrest.Matchers.nullValue; 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.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; @@ -119,6 +121,27 @@ void setup() { """)); } + @Test + void 교수가_미정인_정규강의를_생성한다() throws Exception { + timetableV2Fixture.시간표1(user, semester); + lectureFixture.교수_미정_강의(semester.getSemester()); + + mockMvc.perform( + post("/v3/timetables/lecture/regular") + .header("Authorization", "Bearer " + token) + .content(""" + { + "timetable_frame_id": 1, + "lecture_id": 1 + } + """) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.timetable[0].class_title").value("교수 미정 강의")) + .andExpect(jsonPath("$.timetable[0].professor").value(nullValue())); + } + @Test void 커스텀강의를_생성한다() throws Exception { timetableV2Fixture.시간표1(user, semester); diff --git a/src/test/java/in/koreatech/koin/acceptance/fixture/LectureAcceptanceFixture.java b/src/test/java/in/koreatech/koin/acceptance/fixture/LectureAcceptanceFixture.java index 5ebcf1b7be..39e124b2e7 100644 --- a/src/test/java/in/koreatech/koin/acceptance/fixture/LectureAcceptanceFixture.java +++ b/src/test/java/in/koreatech/koin/acceptance/fixture/LectureAcceptanceFixture.java @@ -75,6 +75,26 @@ public LectureAcceptanceFixture(LectureRepository lectureRepository) { ); } + public Lecture 교수_미정_강의(String semester) { + return lectureRepository.save( + Lecture.builder() + .code("MEB312") + .semester(semester) + .name("교수 미정 강의") + .grades("3") + .lectureClass("01") + .regularNumber("30") + .department("기계공학부") + .target("기공전체") + .professor(null) + .isEnglish("") + .designScore("0") + .isElearning("") + .classTime("[100, 101, 102, 103]") + .build() + ); + } + public Lecture 영어청해(String semester) { return lectureRepository.save( Lecture.builder() diff --git a/src/test/java/in/koreatech/koin/unit/domain/timetableV3/dto/response/TimetableLectureProfessorResponseTest.java b/src/test/java/in/koreatech/koin/unit/domain/timetableV3/dto/response/TimetableLectureProfessorResponseTest.java new file mode 100644 index 0000000000..551ed5b564 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/timetableV3/dto/response/TimetableLectureProfessorResponseTest.java @@ -0,0 +1,94 @@ +package in.koreatech.koin.unit.domain.timetableV3.dto.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV2.model.TimetableLecture; +import in.koreatech.koin.domain.timetableV3.dto.response.TakeAllTimetableLectureResponse; +import in.koreatech.koin.domain.timetableV3.dto.response.TimetableLectureResponseV3; + +@SuppressWarnings("NonAsciiCharacters") +class TimetableLectureProfessorResponseTest { + + @Test + void 강의와_시간표_강의의_교수가_모두_없어도_교수는_null로_응답한다() { + TimetableLecture timetableLecture = timetableLecture(lecture(null), null); + + var response = TimetableLectureResponseV3.InnerTimetableLectureResponseV3 + .from(List.of(timetableLecture)); + + assertThat(response).hasSize(1); + assertThat(response.get(0).professor()).isNull(); + } + + @Test + void 시간표_강의의_교수가_없으면_강의의_교수로_응답한다() { + TimetableLecture timetableLecture = timetableLecture(lecture("허준기"), null); + + var response = TimetableLectureResponseV3.InnerTimetableLectureResponseV3 + .from(List.of(timetableLecture)); + + assertThat(response.get(0).professor()).isEqualTo("허준기"); + } + + @Test + void 시간표_강의에_교수가_있으면_강의의_교수보다_우선한다() { + TimetableLecture timetableLecture = timetableLecture(lecture("허준기"), "수정된 교수"); + + var response = TimetableLectureResponseV3.InnerTimetableLectureResponseV3 + .from(List.of(timetableLecture)); + + assertThat(response.get(0).professor()).isEqualTo("수정된 교수"); + } + + @Test + void 이수한_전체_수업_응답도_교수가_모두_없으면_null로_응답한다() { + TimetableLecture timetableLecture = timetableLecture(lecture(null), null); + + var response = TakeAllTimetableLectureResponse.InnerTimetableLectureResponseV3 + .from(List.of(timetableLecture)); + + assertThat(response).hasSize(1); + assertThat(response.get(0).professor()).isNull(); + } + + @Test + void 이수한_전체_수업_응답은_시간표_강의의_교수가_없으면_강의의_교수로_응답한다() { + TimetableLecture timetableLecture = timetableLecture(lecture("허준기"), null); + + var response = TakeAllTimetableLectureResponse.InnerTimetableLectureResponseV3 + .from(List.of(timetableLecture)); + + assertThat(response.get(0).professor()).isEqualTo("허준기"); + } + + private Lecture lecture(String professor) { + return Lecture.builder() + .code("MEB311") + .semester("20192") + .name("재료역학") + .grades("3") + .lectureClass("01") + .regularNumber("35") + .department("기계공학부") + .target("기공전체") + .professor(professor) + .isEnglish("") + .designScore("0") + .isElearning("") + .classTime("[100, 101, 102, 103, 308, 309]") + .build(); + } + + private TimetableLecture timetableLecture(Lecture lecture, String professor) { + return TimetableLecture.builder() + .lecture(lecture) + .grades("0") + .professor(professor) + .build(); + } +} From 13299e1ee9c6add2440f37f50ed9ab8c05734dc3 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 13:37:54 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=8B=9D=EB=8B=A8=20=ED=92=88?= =?UTF-8?q?=EC=A0=88=20=EC=A0=9C=EB=B3=B4=20=EB=B0=8F=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?/=EB=B0=98=EB=A0=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#2471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 식단 품절 제보 및 승인과 반려 API 구현 * fix: CI 테스트 시간대와 메모리 설정 수정 * feat: 식단 품절 제보 전송 작업 API 구현 --- build.gradle | 1 + docs/web-cookie-auth.md | 191 ------ .../controller/AdminDiningReportApi.java | 61 ++ .../AdminDiningReportController.java | 40 ++ .../dining/dto/AdminDiningReportResponse.java | 101 +++ .../koin/domain/coop/dto/SoldOutRequest.java | 2 +- .../koin/domain/coop/service/CoopService.java | 24 +- .../auth/DiningReportBotInterceptor.java | 37 ++ .../DiningReportDeliveryProperties.java | 22 + .../dining/controller/DiningReportApi.java | 85 +++ .../dining/controller/DiningReportBotApi.java | 203 ++++++ .../controller/DiningReportBotController.java | 78 +++ .../controller/DiningReportController.java | 38 ++ .../domain/dining/dto/DiningReportActor.java | 25 + .../dining/dto/DiningReportCreateRequest.java | 23 + .../dto/DiningReportCreateResponse.java | 30 + .../dto/DiningReportDecisionRequest.java | 13 + .../dto/DiningReportDecisionResponse.java | 22 + .../dto/DiningReportDeliveryResponse.java | 53 ++ .../DiningReportDeliveryResultRequest.java | 215 ++++++ .../DiningReportDeliveryResultResponse.java | 23 + .../dining/dto/DiningReportPageResponse.java | 30 + .../dining/dto/DiningReportResponse.java | 109 +++ .../dto/DiningReportSummaryResponse.java | 49 ++ .../koin/domain/dining/model/Dining.java | 17 + .../domain/dining/model/DiningReport.java | 120 ++++ .../dining/model/DiningReportChange.java | 61 ++ .../dining/model/DiningReportDelivery.java | 132 ++++ .../model/DiningReportDeliveryAttempt.java | 107 +++ .../DiningReportDeliveryFailureReason.java | 7 + .../model/DiningReportDeliveryMode.java | 6 + .../model/DiningReportDeliveryOperation.java | 6 + .../model/DiningReportDeliveryOutcome.java | 7 + .../model/DiningReportDeliveryStatus.java | 9 + .../model/DiningReportDeliveryTarget.java | 115 ++++ .../model/DiningReportProcessingType.java | 5 + .../dining/model/DiningReportSequence.java | 44 ++ .../dining/model/DiningReportStatus.java | 5 + .../dining/model/DiningSoldOutSource.java | 5 + .../DiningReportChangeRepository.java | 16 + ...DiningReportDeliveryAttemptRepository.java | 20 + .../DiningReportDeliveryRepository.java | 15 + .../DiningReportDeliveryTargetRepository.java | 27 + .../repository/DiningReportRepository.java | 54 ++ .../DiningReportSequenceRepository.java | 19 + .../dining/repository/DiningRepository.java | 8 + .../service/DiningReportChangeService.java | 40 ++ .../service/DiningReportDeliveryService.java | 371 +++++++++++ .../service/DiningReportQueryService.java | 60 ++ .../dining/service/DiningReportService.java | 222 +++++++ .../eventlistener/CoopEventListener.java | 31 +- .../service/CoopNotificationService.java | 23 + .../koin/global/code/ApiResponseCode.java | 13 + .../koin/global/config/SwaggerConfig.java | 5 + .../koin/global/config/WebConfig.java | 11 +- .../exception/GlobalExceptionHandler.java | 2 +- .../infrastructure/s3/client/S3Client.java | 4 + .../V12__create_dining_soldout_reports.sql | 78 +++ .../V13__create_dining_report_deliveries.sql | 117 ++++ .../koin/acceptance/domain/DiningApiTest.java | 75 +++ .../domain/DiningReportDeliveryApiTest.java | 629 ++++++++++++++++++ ...ingSoldOutNotificationConcurrencyTest.java | 245 +++++++ .../domain/DiningSoldOutReportApiTest.java | 520 +++++++++++++++ .../DiningSoldOutReportConcurrencyTest.java | 484 ++++++++++++++ .../DiningSoldOutReportMigrationTest.java | 192 ++++++ .../DiningReportImageValidationTest.java | 157 +++++ .../unit/global/auth/WebAuthLoggingTest.java | 3 +- 67 files changed, 5345 insertions(+), 217 deletions(-) delete mode 100644 docs/web-cookie-auth.md create mode 100644 src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportApi.java create mode 100644 src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportController.java create mode 100644 src/main/java/in/koreatech/koin/admin/dining/dto/AdminDiningReportResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/auth/DiningReportBotInterceptor.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/config/DiningReportDeliveryProperties.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotApi.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotController.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportController.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportActor.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultRequest.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportPageResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportSummaryResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReport.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportChange.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDelivery.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryAttempt.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryFailureReason.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryMode.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOperation.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOutcome.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryStatus.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryTarget.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportProcessingType.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportSequence.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningReportStatus.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/model/DiningSoldOutSource.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportChangeRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryAttemptRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryTargetRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportSequenceRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/service/DiningReportChangeService.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/service/DiningReportDeliveryService.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/service/DiningReportQueryService.java create mode 100644 src/main/java/in/koreatech/koin/domain/dining/service/DiningReportService.java create mode 100644 src/main/resources/db/migration/V12__create_dining_soldout_reports.sql create mode 100644 src/main/resources/db/migration/V13__create_dining_report_deliveries.sql create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutNotificationConcurrencyTest.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/dining/service/DiningReportImageValidationTest.java diff --git a/build.gradle b/build.gradle index 895f11b8e8..26db9863c8 100644 --- a/build.gradle +++ b/build.gradle @@ -106,6 +106,7 @@ tasks.named('bootBuildImage') { } tasks.named('test') { + maxHeapSize = '2g' useJUnitPlatform { excludeTags 'web-auth-http' } diff --git a/docs/web-cookie-auth.md b/docs/web-cookie-auth.md deleted file mode 100644 index cbd0efaac5..0000000000 --- a/docs/web-cookie-auth.md +++ /dev/null @@ -1,191 +0,0 @@ -# 웹 HttpOnly 쿠키 인증 - -기존 `/v2/users/login`, `/user/login`, `/user/refresh`, `/user/logout`의 앱용 계약은 유지한다. -웹(PC·모바일 브라우저)은 아래 API로 전환한다. User-Agent의 PC/Mobile 분류로 인증 방식을 선택하지 않는다. -이번 변경은 일반·학생·총학생회 사용자의 웹 인증을 대상으로 하며 사장님·영양사·관리자 로그인은 기존 API를 사용한다. - -## API 계약 - -| 요청 | 입력 | 성공 응답 | -| --- | --- | --- | -| `POST /v2/web/auth/login` | JSON `login_id`, `login_pw`, `auto_login` | 201, 인증 쿠키 2개와 CSRF 일반 쿠키, `user_type`·`csrf_token` | -| `POST /v2/web/auth/refresh` | refresh 쿠키, `X-CSRF-Token` | 201, 교체된 인증 쿠키 2개와 CSRF 일반 쿠키, `user_type`·`csrf_token` | -| `POST /v2/web/auth/logout` | refresh 쿠키, `X-CSRF-Token` | 204, 현재 웹 세션 폐기 및 쿠키 삭제 | -| `GET /v2/web/auth/session` | access·refresh 쿠키(둘 다 선택) | 200 항상. `authenticated`, `user_type`, `csrf_token`. 유효하면 CSRF 쿠키 복구 | -| `GET /v3/users/me` | access 쿠키(또는 Bearer) | 200, 일반·학생·총학생회 공통 내 정보. 학생은 `student_number`, `major` 포함 | - -웹 인증 API는 허용된 `Origin` 또는 `Referer`가 필요하다. 로그인 본문은 `application/json`만 받는다. -`login_pw`는 기존 로그인과 동일하게 SHA-256 처리한 비밀번호를 전달한다. -`auto_login`은 생략하면 false이며, false일 때는 세 쿠키 모두 브라우저 세션 쿠키다. -브라우저의 세션 복원 기능 때문에 브라우저 종료가 서버 세션의 즉시 폐기를 의미하지는 않는다. - -access·refresh 값은 JSON 응답에 포함하지 않는다. CSRF 토큰은 32바이트 난수와 현재 로그인 세션에 연결한 -HMAC-SHA256 서명으로 구성되며 세션 ID는 CSRF 토큰에 노출하지 않는다. 서버는 헤더로 받은 토큰의 서명과 -현재 Redis 세션의 토큰을 함께 검증한다. 쿠키에 담긴 값만으로 인증하거나 쿠키와 헤더의 단순 일치만 확인하지 않는다. -웹 코드는 CSRF 일반 쿠키를 읽어 쿠키 인증 POST/PUT/PATCH/DELETE 요청의 `X-CSRF-Token` 헤더에 넣는다. -CSRF 토큰을 별도 state나 localStorage에 저장할 필요는 없다. 기존 JSON의 `csrf_token`은 호환성을 위해 유지한다. -CSRF 쿠키가 없으면 `/session`을 호출해 복구한다(세션이 유효할 때 CSRF 쿠키를 다시 발급한다). 단순 새로고침마다 호출할 필요는 없다. -로그인 시에는 기존 세션이 없으므로 -허용 출처 검사와 JSON Content-Type 제한으로 로그인 CSRF를 방어한다. - -## 웹 연동 순서 - -1. 모든 웹 API 요청에 `credentials: 'include'` 또는 Axios의 `withCredentials: true`를 설정한다. -2. 로그인 성공 시 화면에 필요한 회원 정보를 반영한다. CSRF 토큰은 요청 시 일반 쿠키에서 읽는다. -3. 기존 `document.cookie`, localStorage, Zustand의 access·refresh 저장 및 Bearer 헤더 주입을 제거한다. -4. 쿠키 인증의 상태 변경 요청에는 CSRF 헤더를 추가한다. 조회 요청은 CSRF 헤더가 필요하지 않지만 허용된 출처여야 한다. -5. access 만료 시 `/refresh`를 한 번 호출한 뒤 원래 요청을 재시도한다. -6. 로그아웃은 서버 `/logout`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. - -로그인·재발급·로그아웃·세션 조회는 만료된 access 쿠키의 영향을 받지 않는다. -일반 기능 API에서는 명시적인 `Authorization` 헤더가 있으면 기존 헤더 인증만 사용한다. -잘못된 헤더를 쿠키 인증으로 대체하지 않으므로, 웹 전환 시 예전 헤더 주입 코드도 제거해야 한다. -기존 앱 토큰을 웹 쿠키로 넣거나 웹 토큰을 기존 앱 인증 경로로 전달하는 것은 허용하지 않는다. -일반 API의 쿠키 인증은 기존 `@Auth`·`@UserId` 파라미터가 있는 메서드에 적용한다. -회원가입·비밀번호 재설정 등 비인증 API는 남아 있는 쿠키 때문에 인증이나 CSRF 헤더를 요구하지 않는다. - -쿠키로 인증하는 일반 API도 GET/HEAD를 포함해 허용된 `Origin` 또는 `Referer`가 필요하다. -기존 채팅 조회 API에는 읽음 상태 변경이 포함되어 있어, 외부 링크 이동만으로 해당 동작이 실행되지 않도록 출처를 확인한다. -브라우저의 정상 fetch/XHR은 Origin 또는 Referer를 전송하지만, `no-referrer` 정책을 적용한 동일 출처 GET이나 -API 주소 직접 입력은 403이 될 수 있다. SSR/프록시도 모든 쿠키 인증 요청에 설정된 웹 Origin을 전달해야 한다. -이 조건은 기존 앱 Bearer 요청이나 쿠키 없는 공개 조회에는 적용하지 않는다. - -`/user/check/login`처럼 토큰을 query로 받는 기존 전용 API는 쿠키 인증으로 전환하지 않는다. -웹의 로그인 상태 확인은 쿠키와 함께 `/user/auth`를 사용한다. - -### 세션 조회 (`/v2/web/auth/session`) - -웹은 로그인 상태를 알기 위해 이 API를 사용한다. 비로그인도 오류가 아니라 `authenticated: false`인 200으로 응답하므로 -401을 정상 응답으로 해석하거나 `/user/auth` 401 → `/refresh` → 재시도를 클라이언트가 조합할 필요가 없다. - -- refresh 쿠키의 세션이 유효하면 access가 만료되었어도 `authenticated: true`이고, 일치하지 않으면 access 쿠키로 확인한다. 토큰은 회전하지 않는다. access는 이후 API 요청이 401일 때 `/refresh`로 재발급한다. -- 세션이 유효하면 CSRF 일반 쿠키를 복구한다. 이 API가 기존 `/csrf` 조회를 대체한다. -- 세션이 저장소에서 사라졌거나(만료·로그아웃·비밀번호 변경·탈퇴) 쿠키 형식이 잘못되면 `authenticated: false`로 응답하며 access·refresh·CSRF 쿠키를 만료시킨다. 이후 "CSRF 쿠키 없음 = 세션 없음"으로 판단할 수 있다. -- refresh 값이 세션과 일치하지 않는 경우는 다른 탭이 이미 회전시켰을 수 있으므로 쿠키를 지우지 않는다. -- 다른 웹 인증 API와 같이 허용된 `Origin` 또는 `Referer`가 필요하고 `Cache-Control: no-store`로 응답한다. - -### 내 정보 조회 (`/v3/users/me`) - -일반(`GENERAL`)·학생(`STUDENT`)·총학생회(`COUNCIL`)가 같은 엔드포인트로 조회한다. 학생·총학생회는 `student_number`, `major`가 함께 내려오고 일반 회원은 두 필드가 null이다. -기존 `/user/student/me`, `/v2/users/me`와 앱 계약은 그대로 유지한다. - - -## 쿠키와 만료 설정 - -| 항목 | 운영(`prod`) 기본값 | -| --- | --- | -| access 쿠키 | `__Secure-koin-web-access`, Domain `koreatech.in`, Path `/`, 기본 15분 | -| refresh 쿠키 | `__Secure-koin-web-refresh`, Path `/v2/web/auth`, 기본 90일 | -| CSRF 쿠키 | `__Secure-koin-web-csrf`, Domain `koreatech.in`, Path `/`, HttpOnly=false, refresh와 동일한 만료 시각 | -| 인증 쿠키 속성 | access·refresh 모두 HttpOnly | -| 공통 기본 속성 | Secure, SameSite=Lax. refresh만 Domain 미지정(host-only) | -| 자동 로그인 false | Max-Age 없는 세션 쿠키. 서버의 만료 시간은 그대로 적용 | - -`WEB_AUTH_ACCESS_TOKEN_TTL`, `WEB_AUTH_REFRESH_TOKEN_TTL`, `WEB_AUTH_COOKIE_SECURE`, -`WEB_AUTH_COOKIE_SAME_SITE` 환경변수로 설정한다. 기존 앱의 JWT 만료 설정은 바꾸지 않는다. -`WEB_AUTH_CSRF_SECRET_KEY`는 새로 필요한 서버 전용 서명 키다. 충분한 난수로 생성한 32바이트 이상의 문자열을 -로컬·stage·운영 실행 환경에 주입한다. 누락되거나 짧으면 서버가 시작되지 않는다. 소스나 클라이언트에 키를 넣지 않는다. -같은 환경의 서버 인스턴스들은 동일한 키를 사용한다. 운영·stage의 `JWT_SECRET_KEY`와 `WEB_AUTH_CSRF_SECRET_KEY`는 -각 서버에 서로 다른 값으로 주입하고 세션 저장소도 환경별로 구분한다. 변수명이 같아도 키 값을 공유하지 않는다. -실제 배포 서버의 키 값은 이 작업에서 변경하거나 확인하지 않았다. 키를 교체하면 기존 웹 세션은 재로그인이 필요하다. -`local` 프로필에서는 HTTP 개발을 위해 Secure=false와 `koin-web-access`·`koin-web-refresh`·`koin-web-csrf` 이름을 사용한다. -운영에서는 Secure를 유지해야 하며 SameSite=None은 Secure 없이 설정할 수 없다. -`CORS_ALLOWED_ORIGINS`에는 실제 웹의 정확한 origin을 넣는다. `*`나 도메인 접미사 비교로 웹 요청을 허용하지 않는다. - -프로필별 기본 공유 범위와 쿠키 이름은 다음과 같다. stage는 프로젝트의 기존 `dev` 프로필을 사용한다. - -| 환경 | 프로필 | access·CSRF Domain | 쿠키 이름 접두어 | -| --- | --- | --- | --- | -| 운영 웹·order | `prod` | `koreatech.in` | `koin-web` | -| stage 웹 | `dev` | `stage.koreatech.in` | `koin-stage-web` | -| 로컬·테스트 | `local` / `test` | 미지정 | `koin-web` | - -프론트가 읽는 CSRF 쿠키 이름은 운영 `__Secure-koin-web-csrf`, stage `__Secure-koin-stage-web-csrf`다. -SSR에서 전달할 access 쿠키도 각각 `__Secure-koin-web-access`, `__Secure-koin-stage-web-access`를 사용한다. -refresh는 환경과 관계없이 해당 API 호스트와 `/v2/web/auth` 경로에만 보낸다. -두 환경의 쿠키가 함께 전달되어도 각 API는 자기 이름의 쿠키만 읽는다. -쿠키 이름을 바꿔 제출하더라도 서로 다른 JWT·HMAC 서명 키와 해당 환경의 세션으로 검증한다. - -`WEB_AUTH_SHARED_COOKIE_DOMAIN`, `WEB_AUTH_COOKIE_NAME_PREFIX`로 프로필 기본값을 덮어쓸 수 있다. -이름을 재정의하더라도 운영·stage의 쿠키 이름 접두어는 서로 다르게 유지한다. -공유 Domain을 명시적으로 빈 값으로 설정하면 host-only로 돌아가며, Secure access·CSRF 이름도 `__Host-`로 바뀐다. -발급·조회·삭제·Swagger에 같은 설정을 적용하므로 프론트는 실제 서버 설정과 동일한 쿠키 이름을 사용해야 한다. - -운영의 Domain `koreatech.in`은 stage를 포함한 모든 하위 주소에도 쿠키를 전달한다. 이 범위는 합의한 공유 정책이며, -쿠키 이름·키 분리는 다른 환경에서의 오인증/충돌을 막는 것이지 전송 자체를 막는 것은 아니다. -하위 서버의 Cookie 로그·프록시 처리를 보호해야 하며 HMAC은 유효한 토큰 탈취나 XSS를 해결하지 않는다. -실제 서버 환경변수 변경과 운영·stage 배포는 이 코드 변경에 포함하지 않는다. - -## 세션과 동시 요청 - -웹은 로그인마다 `webAuthSession:<랜덤 세션 ID>`를 생성한다. 앱의 기존 Redis 키와 독립적이며 DB 마이그레이션은 없다. -refresh 원문은 저장하지 않고 SHA-256 해시를 저장한다. 재발급 시 refresh를 교체하되 로그인 시 정한 절대 만료 시간은 연장하지 않는다. -웹 access JWT에도 세션 ID를 넣고 요청마다 Redis 세션을 확인하므로 웹 로그아웃 후 남은 access도 사용할 수 없다. -웹 인증 시 DB에서도 계정의 존재 여부를 확인하므로 `@UserId` API도 탈퇴 계정의 쿠키를 받지 않는다. -이 때문에 쿠키 인증은 Redis와 사용자 DB 가용성에 의존하며, 조회 실패 시 인증을 허용하지 않는다. -Redis 전용 로그아웃과 세션 조회(사용자 조회는 트랜잭션 없이 단건 읽기)에는 SQL 트랜잭션을 만들지 않고, access 인증도 Redis 대기 전에 SQL 트랜잭션을 시작하지 않는다. - -Redis의 원자적 비교·교체로 같은 refresh의 동시 갱신은 하나만 성공한다. 먼저 읽은 상태가 다른 요청에 의해 변경되면 409, -이미 교체된 refresh를 새로 제출하면 401이다. 실패 응답은 쿠키를 삭제하거나 덮어쓰지 않는다. -웹은 여러 탭까지 고려해 갱신을 직렬화하고, 갱신과 로그아웃도 동시에 보내지 않아야 한다. -409는 진행 중인 요청을 기다린 뒤 최신 쿠키로 제한적으로 재시도하고, -다른 요청의 갱신이 확인된 401도 최신 쿠키로 한 번만 재시도한다. 재시도에도 인증되지 않으면 재로그인한다. -응답 자체를 받지 못해 교체된 refresh 쿠키를 잃은 경우에는 재로그인이 필요하다. - -비밀번호가 바뀌면 다음 재발급에서 기존 웹 세션을 거부한다. 비밀번호 변경과 동시에 모든 access가 폐기되는 정책은 아니다. -회원의 기기 전체 로그아웃 목록·관리 API는 이번 변경에 포함하지 않는다. - -## SSR·웹뷰 연동 범위 - -전체 요청을 Next.js로 중계하는 BFF로 전환하지 않는다. SSR은 Next.js 서버에서 백엔드를 호출하고, -브라우저에서 발생하는 조회·생성·수정·삭제 요청은 기존처럼 백엔드를 직접 호출한다. -운영·stage의 SSR은 위 프로필별 공유 범위와 쿠키 이름을 사용한다. 프론트는 환경에 맞는 쿠키를 읽어야 한다. -공유 범위를 빈 값으로 덮어써 host-only를 사용하면 다른 호스트의 프론트 서버는 API 쿠키를 받을 수 없다. -SSR 서버는 받은 access 쿠키를 백엔드 `Cookie` 헤더로 전달하고 설정된 웹 `Origin`도 함께 전달한다. -웹 access 토큰을 기존 Bearer 경로로 변환하지 않는다. SSR 중 자동 재발급하는 새 흐름은 추가하지 않으며, -브라우저의 기존 재발급·재시도 처리를 `/v2/web/auth/refresh`와 CSRF 헤더에 연결한다. -SSR props, hydration, React Query 캐시 등 브라우저로 직렬화하는 데이터에 인증 토큰을 넣으면 안 된다. - -앱 내부 웹뷰에서 토큰을 쿠키나 localStorage에 직접 주입하는 기존 연결은 별도 전환이 필요할 수 있다. -이 브랜치는 앱 웹뷰의 자동 로그인 교환이나 STOMP 쿠키 인증을 추가하지 않는다. 기존 STOMP 헤더 인증은 유지한다. -실제 브라우저·SSR·웹뷰·stage 도메인 통합 검증은 해당 클라이언트 전환 후 수행해야 한다. - -## 확인 방법 - -Java 17과 Docker를 사용한다. - -```sh -./gradlew build -``` - -`WebAuthApiTest`는 실제 테스트 DB·Redis를 연결한 MockMvc로 쿠키 속성, 앱 호환성, -권한 검사, 만료·재발급·로그아웃, CSRF·CORS, 토큰 전달 경로 분리를 검증한다. -`WebAuthSessionRedisRepositoryTest`는 실제 Redis에서 동시 갱신과 로그아웃 후 세션 복구 방지를 검증한다. -`WebAuthHttpTest`는 임의 포트의 실제 HTTP 서버에서 로그인·정보 조회·CSRF 헤더 누락 거부·CSRF 복구·재발급·로그아웃을 검증한다. -`webAuthHttpTest` 태스크로 별도 JVM에서 실행하며 `build`의 `check`에도 포함한다. -HTTP 클라이언트가 Cookie 헤더를 명시적으로 전달하는 테스트이며 실제 브라우저의 쿠키 저장/전송이나 프론트 SSR E2E는 아니다. -단위 테스트는 출처 검사, JWT 경로 분리, 세션 검증, 로컬 쿠키 설정과 로그 마스킹을 확인한다. -`WebAuthCompatibilityTest`는 기존 이메일 로그인, 관리자 접근 거부, 선택 인증의 개인화·익명 응답, -학생 쿠키 로그인과 학생 정보 조회를 확인한다. - -Redis 장애는 서비스 mock 예외 주입과 컨트롤러의 500/409·Set-Cookie 미발급으로 검증한다. -실제 Redis 중단·복구나 네트워크 지연, 동시 HTTP 응답 순서, SQL 연결 풀 부하 시험은 수행하지 않았다. -탈퇴 계정 검증은 테스트 DB의 사용자 삭제·flush·clear 후 쿠키 접근과 재발급 거부를 확인한 것이며, -실제 탈퇴 API의 커밋·이벤트까지 포함한 E2E는 아니다. - -### 로컬 검증 결과 (2026-09-10) - -- 이슈: [KOIN_API_V2 #2424](https://github.com/BCSDLab/KOIN_API_V2/issues/2424) -- 로컬 브랜치: `feat/2424-web-httponly-auth` -- 기준 브랜치: `origin/develop`의 `3d14db39` -- Java 17, 실제 테스트 MySQL·Redis·MongoDB를 사용한 전체 `build` 성공 -- 전체 1,243개 중 1,240개 통과, 기존 비활성 테스트 3개 건너뜀, 실패 0개 -- 이번에 추가한 웹 인증 테스트 94개 모두 통과 -- GET/HEAD 출처 검사와 잘못된 JSON 로그 마스킹 회귀 테스트 3개는 수정 전 실패, 수정 후 통과 확인 -- 보안·세션·호환성 에이전트의 1차 검토를 반영하고 수정 후 재검토 완료. 추가 차단 결함 없음 -- 로컬 `build/`에 중복된 `Test 2.class` 산출물이 발견되어, 검증에서는 임시 Gradle init script로 - buildDirectory만 `/tmp/koin-backend-2424-build`로 분리했다. 프로젝트의 Gradle 빌드 설정은 변경하지 않았다. -- 이번 작업에서는 프론트·앱 코드를 변경하지 않았고 실제 웹 화면·SSR·앱 웹뷰 연동과 운영/stage 검증은 수행하지 않았다. - -쿠키 동작은 [MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie), -요청 위조 방어는 [OWASP CSRF 방어 지침](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)을 참고했다. diff --git a/src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportApi.java b/src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportApi.java new file mode 100644 index 0000000000..e13c9a1ccf --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportApi.java @@ -0,0 +1,61 @@ +package in.koreatech.koin.admin.dining.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_USER_TYPE; +import static in.koreatech.koin.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_DINING_REPORT; +import static in.koreatech.koin.global.code.ApiResponseCode.OK; +import static in.koreatech.koin.global.code.ApiResponseCode.UNAUTHORIZED_USER; +import static in.koreatech.koin.global.code.ApiResponseCode.WITHDRAWN_USER; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.admin.dining.dto.AdminDiningReportResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportPageResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportSummaryResponse; +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.media.Schema; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(ADMIN) Dining: 식단 품절 제보 조회") +@SecurityRequirement(name = "Jwt Authentication") +public interface AdminDiningReportApi { + + @ApiResponseCodes({OK, ILLEGAL_ARGUMENT, UNAUTHORIZED_USER, WITHDRAWN_USER, FORBIDDEN_USER_TYPE, FORBIDDEN_ADMIN}) + @Operation(summary = "식단 품절 제보 목록 조회", description = """ + - 인증된 ADMIN이 제보 요약을 조회합니다. 관리자 API는 조회 전용입니다. + - only_pending=true이면 PENDING 제보만 반환하며, 기본값 false이면 모든 상태를 조회합니다. + - 접수 시각·제보 ID 내림차순으로 반환하며, 조건에 맞는 제보가 없으면 빈 목록을 반환합니다. + """) + @GetMapping("/admin/dining/soldout-reports") + ResponseEntity> getReports( + @Auth(permit = ADMIN) Integer adminId, + @Parameter(description = "대기 제보만 조회할지 여부", example = "false", schema = @Schema(defaultValue = "false")) + @RequestParam(name = "only_pending", defaultValue = "false") boolean onlyPending, + @Parameter(description = "페이지 번호. 1부터 시작하며 전체 페이지 범위로 보정합니다.", example = "1", + schema = @Schema(defaultValue = "1")) + @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "페이지당 제보 수. 1~50으로 보정합니다.", example = "10", + schema = @Schema(defaultValue = "10")) + @RequestParam(defaultValue = "10") Integer limit); + + @ApiResponseCodes({ + OK, ILLEGAL_ARGUMENT, UNAUTHORIZED_USER, WITHDRAWN_USER, + FORBIDDEN_USER_TYPE, FORBIDDEN_ADMIN, NOT_FOUND_DINING_REPORT + }) + @Operation(summary = "식단 품절 제보 상세 조회", + description = "인증된 ADMIN이 사진·처리 이력·제보자 정보를 조회합니다. 관리자 API는 조회 전용입니다.") + @GetMapping("/admin/dining/soldout-reports/{reportId}") + ResponseEntity getReport( + @Auth(permit = ADMIN) Integer adminId, + @Parameter(description = "조회할 제보 ID", example = "1", required = true) + @PathVariable Integer reportId); +} diff --git a/src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportController.java b/src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportController.java new file mode 100644 index 0000000000..cdde049584 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/dining/controller/AdminDiningReportController.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.admin.dining.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.dining.dto.AdminDiningReportResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportPageResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportSummaryResponse; +import in.koreatech.koin.domain.dining.service.DiningReportQueryService; +import in.koreatech.koin.global.auth.Auth; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminDiningReportController implements AdminDiningReportApi { + + private final DiningReportQueryService diningReportQueryService; + + @GetMapping("/admin/dining/soldout-reports") + public ResponseEntity> getReports( + @Auth(permit = ADMIN) Integer adminId, + @RequestParam(name = "only_pending", defaultValue = "false") boolean onlyPending, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer limit + ) { + return ResponseEntity.ok(diningReportQueryService.getAdminReports(onlyPending, page, limit)); + } + + @GetMapping("/admin/dining/soldout-reports/{reportId}") + public ResponseEntity getReport( + @Auth(permit = ADMIN) Integer adminId, @PathVariable Integer reportId + ) { + return ResponseEntity.ok(diningReportQueryService.getAdminReport(reportId)); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/dining/dto/AdminDiningReportResponse.java b/src/main/java/in/koreatech/koin/admin/dining/dto/AdminDiningReportResponse.java new file mode 100644 index 0000000000..12bf617597 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/dining/dto/AdminDiningReportResponse.java @@ -0,0 +1,101 @@ +package in.koreatech.koin.admin.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dining.dto.DiningReportActor; +import in.koreatech.koin.domain.dining.dto.DiningReportResponse; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportProcessingType; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.ALWAYS) +@Schema(description = "관리자 조회용 제보 상세. 제보자 정보를 함께 반환합니다.", example = """ + { + "report_id": 1, + "dining": {"id": 1, "date": "2026-10-02", "type": "LUNCH", "place": "A코너"}, + "status": "APPROVED", + "processing_type": "MANUAL", + "reason": "담당자 승인", + "created_at": "2026-10-02 12:00:00", + "processed_at": "2026-10-02 12:01:00", + "processor": {"workspace_id": "T0123456789", "user_id": "U0123456789", "display_name": "홍길동"}, + "image_url": "https://static.koreatech.in/upload/COOP/2026/10/2/e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg", + "processing_id": "550e8400-e29b-41d4-a716-446655440000", + "source_report_id": null, + "updated_at": "2026-10-02 12:01:00", + "reporter": {"id": 10, "name": "김코인"} + } + """) +public record AdminDiningReportResponse( + @Schema(description = "제보 ID", example = "1", requiredMode = REQUIRED) + Integer reportId, + @Schema(description = "제보 대상 식단", requiredMode = REQUIRED) + DiningReportResponse.DiningReference dining, + @Schema(description = "제보 상태. PENDING은 대기, APPROVED는 승인, REJECTED는 반려입니다.", + example = "APPROVED", requiredMode = REQUIRED) + DiningReportStatus status, + @Schema(description = "MANUAL은 담당자 처리, SAME_DINING_APPROVED는 같은 식단 승인에 따른 자동 승인, " + + "COOP_PREPROCESSED는 영양사 품절에 따른 자동 반려입니다. 대기 중이면 null입니다.", + example = "MANUAL", nullable = true, requiredMode = REQUIRED) + DiningReportProcessingType processingType, + @Schema(description = "처리 사유. 대기 중이면 null입니다.", example = "담당자 승인", nullable = true, requiredMode = REQUIRED) + String reason, + @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, + @Schema(description = "담당자 수동 처리 시각 (한국시간). 대기·자동 처리이면 null입니다.", example = "2026-10-02 12:01:00", + implementation = String.class, type = "string", pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", + nullable = true, requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime processedAt, + @Schema(description = "담당자 수동 처리의 감사 정보. 대기·자동 처리이면 null입니다.", nullable = true, requiredMode = REQUIRED) + DiningReportActor processor, + @Schema(description = "접수한 공용 COOP 업로드의 원본 사진 URL", + example = "https://static.koreatech.in/upload/COOP/2026/10/2/" + + "e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg", format = "uri", requiredMode = REQUIRED) + String imageUrl, + @Schema(description = "함께 처리한 제보가 공유하는 처리 묶음 UUID. 대기 중이면 null입니다.", + example = "550e8400-e29b-41d4-a716-446655440000", type = "string", format = "uuid", + nullable = true, requiredMode = REQUIRED) + UUID processingId, + @Schema(description = "같은 식단의 자동 승인을 발생시킨 제보 ID. SAME_DINING_APPROVED일 때만 값이 있고 그 외에는 null입니다.", + example = "1", nullable = true, requiredMode = REQUIRED) + Integer sourceReportId, + @Schema(description = "마지막 상태 변경 시각 (한국시간)", example = "2026-10-02 12:01: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 updatedAt, + @Schema(description = "제보자 정보. 사용자 정보를 조회할 수 없으면 null입니다.", nullable = true, requiredMode = REQUIRED) + Reporter reporter +) { + public static AdminDiningReportResponse from(DiningReport report, User user) { + DiningReportResponse detail = DiningReportResponse.from(report); + Reporter reporter = user == null ? null : new Reporter(user.getId(), + StringUtils.hasText(user.getName()) ? user.getName() + : StringUtils.hasText(user.getNickname()) ? user.getNickname() : "회원"); + return new AdminDiningReportResponse(detail.reportId(), detail.dining(), detail.status(), + detail.processingType(), detail.reason(), detail.createdAt(), detail.processedAt(), detail.processor(), + detail.imageUrl(), detail.processingId(), detail.sourceReportId(), detail.updatedAt(), reporter); + } + + public record Reporter( + @Schema(description = "제보자 사용자 ID", example = "10", requiredMode = REQUIRED) + Integer id, + @Schema(description = "제보자 이름. 이름이 없으면 닉네임 또는 회원을 반환합니다.", example = "김코인", requiredMode = REQUIRED) + String name + ) { + } +} diff --git a/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java b/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java index 47df7d5ad1..7ccf478222 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java +++ b/src/main/java/in/koreatech/koin/domain/coop/dto/SoldOutRequest.java @@ -16,7 +16,7 @@ public record SoldOutRequest( @Schema(description = "품절 여부", example = "true", requiredMode = REQUIRED) @NotNull(message = "품절 여부는 필수입니다.") - boolean soldOut + Boolean soldOut ) { } diff --git a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java index 6bcb4c295a..d41d9a38a3 100644 --- a/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java +++ b/src/main/java/in/koreatech/koin/domain/coop/service/CoopService.java @@ -39,6 +39,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import net.lingala.zip4j.ZipFile; @@ -46,7 +47,6 @@ import in.koreatech.koin.admin.abtest.useragent.UserAgentInfo; import in.koreatech.koin.common.event.DiningImageUploadEvent; -import in.koreatech.koin.common.event.DiningSoldOutEvent; import in.koreatech.koin.domain.coop.dto.CoopLoginRequest; import in.koreatech.koin.domain.coop.dto.CoopLoginResponse; import in.koreatech.koin.domain.coop.dto.CoopResponse; @@ -57,11 +57,9 @@ import in.koreatech.koin.domain.coop.exception.DuplicateExcelRequestException; import in.koreatech.koin.domain.coop.exception.StartDateAfterEndDateException; import in.koreatech.koin.domain.coop.model.Coop; -import in.koreatech.koin.domain.coop.model.DiningSoldOutCache; import in.koreatech.koin.domain.coop.model.ExcelDownloadCache; import in.koreatech.koin.domain.coop.repository.CoopRepository; import in.koreatech.koin.domain.coop.repository.DiningNotifyCacheRepository; -import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; import in.koreatech.koin.domain.coop.repository.ExcelDownloadCacheRepository; import in.koreatech.koin.domain.coopshop.model.CoopShopType; import in.koreatech.koin.domain.coopshop.service.CoopShopService; @@ -69,6 +67,7 @@ import in.koreatech.koin.domain.dining.model.DiningType; import in.koreatech.koin.domain.dining.model.enums.ExcelDiningPosition; import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.dining.service.DiningReportService; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; import in.koreatech.koin.domain.user.service.RefreshTokenService; @@ -85,7 +84,7 @@ public class CoopService { private final Clock clock; private final ApplicationEventPublisher eventPublisher; private final DiningRepository diningRepository; - private final DiningSoldOutCacheRepository diningSoldOutCacheRepository; + private final DiningReportService diningReportService; private final ExcelDownloadCacheRepository excelDownloadCacheRepository; private final DiningNotifyCacheRepository diningNotifyCacheRepository; private final CoopRepository coopRepository; @@ -105,22 +104,9 @@ public class CoopService { private static final int cornerColumnIndex = 1; private static final int mealAndColumnWidth = 4000; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public void changeSoldOut(SoldOutRequest soldOutRequest) { - Dining dining = diningRepository.getById(soldOutRequest.menuId()); - - if (soldOutRequest.soldOut()) { - LocalDateTime now = LocalDateTime.now(clock); - dining.setSoldOut(now); - boolean isOpened = coopShopService.getIsOpened(now, CoopShopType.CAFETERIA, dining.getType(), false); - if (isOpened && diningSoldOutCacheRepository.findById(dining.getPlace()).isEmpty()) { - diningSoldOutCacheRepository.save(DiningSoldOutCache.from(dining.getPlace())); - eventPublisher.publishEvent( - new DiningSoldOutEvent(dining.getId(), dining.getPlace(), dining.getType())); - } - } else { - dining.cancelSoldOut(); - } + diningReportService.changeSoldOutByCoop(soldOutRequest.menuId(), soldOutRequest.soldOut()); } @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/dining/auth/DiningReportBotInterceptor.java b/src/main/java/in/koreatech/koin/domain/dining/auth/DiningReportBotInterceptor.java new file mode 100644 index 0000000000..2aba5218dd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/auth/DiningReportBotInterceptor.java @@ -0,0 +1,37 @@ +package in.koreatech.koin.domain.dining.auth; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.security.MessageDigest; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class DiningReportBotInterceptor implements HandlerInterceptor { + + private static final String SERVICE_TOKEN_HEADER = "X-Koin-Service-Token"; + + private final String botToken; + + public DiningReportBotInterceptor(@Value("${dining.report.bot-token:}") String botToken) { + this.botToken = botToken; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String serviceToken = request.getHeader(SERVICE_TOKEN_HEADER); + if (!StringUtils.hasText(botToken) || !StringUtils.hasText(serviceToken) + || !MessageDigest.isEqual(botToken.getBytes(UTF_8), serviceToken.getBytes(UTF_8))) { + throw CustomException.of(ApiResponseCode.BOT_AUTHENTICATION_FAILED); + } + return true; + } +} 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 new file mode 100644 index 0000000000..6a5284c92b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/config/DiningReportDeliveryProperties.java @@ -0,0 +1,22 @@ +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/DiningReportApi.java b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java new file mode 100644 index 0000000000..c454ea6b31 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportApi.java @@ -0,0 +1,85 @@ +package in.koreatech.koin.domain.dining.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static in.koreatech.koin.global.code.ApiResponseCode.CREATED; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_ALREADY_SOLD_OUT; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_ALREADY_SUBMITTED; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_DATE_NOT_ALLOWED; +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; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REPORT_IMAGE; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_DINING; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_READABLE_HTTP_MESSAGE; +import static in.koreatech.koin.global.code.ApiResponseCode.OPTIMISTIC_LOCKING_FAILURE; +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; + +@Tag(name = "(Normal) Dining: 식단 품절 제보") +@SecurityRequirement(name = "Jwt Authentication") +public interface DiningReportApi { + + @ApiResponseCodes({ + CREATED, + ILLEGAL_ARGUMENT, + INVALID_REQUEST_BODY, + NOT_READABLE_HTTP_MESSAGE, + DINING_REPORT_DATE_NOT_ALLOWED, + INVALID_REPORT_IMAGE, + UNAUTHORIZED_USER, + WITHDRAWN_USER, + FORBIDDEN_USER_TYPE, + FORBIDDEN_STUDENT, + FORBIDDEN_WEB_ORIGIN, + INVALID_CSRF_TOKEN, + 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를 반환합니다. 처리 후에도 재제보할 수 없습니다. + """) + @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 new file mode 100644 index 0000000000..7b2f575469 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotApi.java @@ -0,0 +1,203 @@ +package in.koreatech.koin.domain.dining.controller; + +import static in.koreatech.koin.global.code.ApiResponseCode.BOT_AUTHENTICATION_FAILED; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_ALREADY_PROCESSED; +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_DELIVERY_CONFLICT; +import static in.koreatech.koin.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static in.koreatech.koin.global.code.ApiResponseCode.NO_CONTENT; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_DINING; +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 static in.koreatech.koin.global.code.ApiResponseCode.NOT_READABLE_HTTP_MESSAGE; +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 org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.domain.dining.dto.DiningReportDecisionRequest; +import in.koreatech.koin.domain.dining.dto.DiningReportDecisionResponse; +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.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.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +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: 식단 품절 제보") +@SecurityRequirement(name = "Bot Service Authentication") +public interface DiningReportBotApi { + + @ApiResponseCodes({OK, NO_CONTENT, BOT_AUTHENTICATION_FAILED}) + @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 = """ + - 인증 헤더를 넣고 본문 없이 호출하면 지금 수행할 수 있는 작업 한 건을 배정합니다. 요청마다 새 작업을 조회합니다. + - 작업이 있으면 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 응답을 잃었다면 다음 요청이 같은 작업을 반환한다고 보장하지 않습니다. 기존 시도의 복구 절차를 따릅니다. + """, 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") + ResponseEntity claimDiningReportDelivery(); + + @ApiResponseCodes({ + 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를 반환합니다. + """, 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") + ResponseEntity reportDiningReportDeliveryResult( + @Parameter(description = "배정받은 delivery_id", required = true, + schema = @Schema(type = "string", format = "uuid", pattern = DiningReportDeliveryResultRequest.UUID_PATTERN)) + @PathVariable String deliveryId, @Valid @RequestBody DiningReportDeliveryResultRequest request); + + @ApiResponseCodes({ + OK, ILLEGAL_ARGUMENT, INVALID_REQUEST_BODY, NOT_READABLE_HTTP_MESSAGE, + BOT_AUTHENTICATION_FAILED, NOT_FOUND_DINING_REPORT, NOT_FOUND_DINING, + DINING_REPORT_ALREADY_PROCESSED, OPTIMISTIC_LOCKING_FAILURE + }) + @SecurityRequirement(name = "Bot Service Authentication") + @Operation(summary = "식단 품절 제보 승인", description = """ + - 서비스 토큰으로 인증하며 actor는 처리자 감사 기록에만 사용합니다. + - 대상 제보를 승인하면 같은 식단의 PENDING 제보만 같은 처리 묶음으로 자동 승인하고 식단을 품절 처리합니다. + - 대상에 처리자를 기록하고 자동 승인된 제보는 대상의 source_report_id를 기록합니다. + - 이미 승인된 제보를 재호출하면 최초 처리 결과·처리자·시각·처리 묶음을 유지합니다. 이미 반려되었다면 409를 반환합니다. + - 영양사가 먼저 품절 처리하면 해당 식단의 PENDING 제보는 즉시 자동 반려됩니다. + - affected_report_ids로 함께 처리한 제보를 확인하고 다음 전송 작업을 가져옵니다. 승인 응답으로 슬랙을 직접 수정하지 않습니다. + - already_processed는 업무 처리 재호출 여부이며 슬랙 반영 완료를 뜻하지 않습니다. + """) + @PostMapping("/internal/dining/soldout-reports/{reportId}/approve") + ResponseEntity approveReport( + @Parameter(description = "승인할 제보 ID", example = "1", required = true) + @PathVariable Integer reportId, @Valid @RequestBody DiningReportDecisionRequest request); + + @ApiResponseCodes({ + OK, ILLEGAL_ARGUMENT, INVALID_REQUEST_BODY, NOT_READABLE_HTTP_MESSAGE, + BOT_AUTHENTICATION_FAILED, NOT_FOUND_DINING_REPORT, NOT_FOUND_DINING, + DINING_REPORT_ALREADY_PROCESSED, OPTIMISTIC_LOCKING_FAILURE + }) + @SecurityRequirement(name = "Bot Service Authentication") + @Operation(summary = "식단 품절 제보 반려", description = """ + - 서비스 토큰으로 인증하며 actor는 처리자 감사 기록에만 사용합니다. + - 대상 PENDING 제보만 반려하고 다른 제보와 식단의 품절 상태를 유지합니다. + - 이미 반려된 제보를 재호출하면 최초 처리 결과·처리자·시각·처리 묶음을 유지합니다. 이미 승인되었다면 409를 반환합니다. + - 영양사가 먼저 품절 처리한 제보도 기존 COOP_PREPROCESSED, REJECTED 결과를 반환합니다. + - 슬랙 메시지는 다음 전송 작업으로 반영합니다. 반려 응답으로 메시지를 직접 수정하거나 전송 완료로 판단하지 않습니다. + """) + @PostMapping("/internal/dining/soldout-reports/{reportId}/reject") + ResponseEntity rejectReport( + @Parameter(description = "반려할 제보 ID", example = "1", required = true) + @PathVariable Integer reportId, @Valid @RequestBody DiningReportDecisionRequest request); + + @ApiResponseCodes({OK, ILLEGAL_ARGUMENT, BOT_AUTHENTICATION_FAILED, NOT_FOUND_DINING_REPORT}) + @SecurityRequirement(name = "Bot Service Authentication") + @Operation(summary = "삐봇 식단 품절 제보 상세 조회", + description = """ + - 제보의 식단과 사진, 현재 처리 결과를 조회합니다. 제보자 정보는 반환하지 않습니다. + - 슬랙 메시지는 배정받은 report로 작성하며 상세 조회 결과로 작업 내용을 바꾸거나 직접 메시지를 수정하지 않습니다. + - 수동 처리에는 processor와 processed_at이 있고 대기 중이거나 자동 처리된 경우에는 null입니다. 자동 처리 시각은 updated_at입니다. + """) + @GetMapping("/internal/dining/soldout-reports/{reportId}") + ResponseEntity getReport( + @Parameter(description = "조회할 제보 ID", example = "1", required = true) + @PathVariable Integer reportId); + + @Component + class DeliverySchemaCustomizer implements GlobalOpenApiCustomizer { + + @Override + public void customise(OpenAPI openApi) { + if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { + return; + } + // 기존 Swagger 생성기는 클래스의 additionalProperties=false 어노테이션을 누락합니다. + openApi.getComponents().getSchemas().forEach((name, schema) -> { + if (name.startsWith("DiningReportDelivery") + && ("object".equals(schema.getType()) || schema.getOneOf() != null)) { + 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 new file mode 100644 index 0000000000..0fbcf23c29 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportBotController.java @@ -0,0 +1,78 @@ +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; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.dining.dto.DiningReportDecisionRequest; +import in.koreatech.koin.domain.dining.dto.DiningReportDecisionResponse; +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.service.DiningReportDeliveryService; +import in.koreatech.koin.domain.dining.service.DiningReportQueryService; +import in.koreatech.koin.domain.dining.service.DiningReportService; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DiningReportBotController implements DiningReportBotApi { + + private final DiningReportService diningReportService; + private final DiningReportQueryService diningReportQueryService; + private final DiningReportDeliveryService diningReportDeliveryService; + + @PostMapping("/internal/dining/soldout-reports/deliveries/claim") + public ResponseEntity claimDiningReportDelivery() { + return diningReportDeliveryService.claim() + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.noContent().header(HttpHeaders.RETRY_AFTER, "5").build()); + } + + @PostMapping("/internal/dining/soldout-reports/deliveries/{deliveryId}/result") + public ResponseEntity reportDiningReportDeliveryResult( + @PathVariable String deliveryId, @Valid @RequestBody DiningReportDeliveryResultRequest request + ) { + 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()); + } + + @PostMapping("/internal/dining/soldout-reports/{reportId}/approve") + public ResponseEntity approveReport( + @PathVariable Integer reportId, @Valid @RequestBody DiningReportDecisionRequest request + ) { + return ResponseEntity.ok(diningReportService.approve(reportId, request.actor())); + } + + @PostMapping("/internal/dining/soldout-reports/{reportId}/reject") + public ResponseEntity rejectReport( + @PathVariable Integer reportId, @Valid @RequestBody DiningReportDecisionRequest request + ) { + return ResponseEntity.ok(diningReportService.reject(reportId, request.actor())); + } + + @GetMapping("/internal/dining/soldout-reports/{reportId}") + public ResponseEntity getReport(@PathVariable Integer reportId) { + return ResponseEntity.ok(diningReportQueryService.getBotReport(reportId)); + } +} 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 new file mode 100644 index 0000000000..99e5f946e6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/controller/DiningReportController.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.dining.controller; + +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; +import in.koreatech.koin.domain.dining.dto.DiningReportCreateResponse; +import in.koreatech.koin.domain.dining.service.DiningReportService; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DiningReportController implements DiningReportApi { + + private final DiningReportService diningReportService; + + @PostMapping("/dinings/{diningId}/soldout-reports") + 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)); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportActor.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportActor.java new file mode 100644 index 0000000000..8d76d46a07 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportActor.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "삐봇 처리자 감사 정보. 서비스 토큰으로 인증하고 이 정보는 감사 기록에만 사용합니다.") +public record DiningReportActor( + @Schema(description = "처리자의 Slack 워크스페이스 ID. 공백만 입력할 수 없습니다.", example = "T0123456789", + minLength = 1, maxLength = 64, requiredMode = REQUIRED) + @NotBlank @Size(max = 64) String workspaceId, + @Schema(description = "처리자의 Slack 사용자 ID. 공백만 입력할 수 없습니다.", example = "U0123456789", + minLength = 1, maxLength = 64, requiredMode = REQUIRED) + @NotBlank @Size(max = 64) String userId, + @Schema(description = "처리자 표시 이름. 공백만 입력할 수 없습니다.", example = "홍길동", + minLength = 1, maxLength = 80, requiredMode = REQUIRED) + @NotBlank @Size(max = 80) String displayName +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateRequest.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateRequest.java new file mode 100644 index 0000000000..6b26237261 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record DiningReportCreateRequest( + @Schema(description = "공용 COOP 업로드의 file_url. 원본 URL을 그대로 보관합니다. " + + "쿼리와 프래그먼트가 없는 upload/COOP/ 경로의 존재하는 파일이어야 합니다.", + example = "https://static.koreatech.in/upload/COOP/2026/10/2/" + + "e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg", + format = "uri", minLength = 1, maxLength = 2048, requiredMode = REQUIRED) + @NotBlank(message = "제보 사진 URL은 필수입니다.") + @Size(max = 2048, message = "제보 사진 URL은 2048자 이하여야 합니다.") + String imageUrl +) { +} 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 new file mode 100644 index 0000000000..e39deda85f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportCreateResponse.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record DiningReportCreateResponse( + @Schema(description = "제보 ID. 동일 요청 키의 재시도에서도 최초 ID를 유지합니다.", example = "1", requiredMode = REQUIRED) + Integer reportId, + @Schema(description = "최초 접수 상태. 재시도에서도 최초 접수 결과를 반환합니다.", example = "PENDING", + allowableValues = {"PENDING"}, requiredMode = REQUIRED) + DiningReportStatus status, + @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 +) { + public static DiningReportCreateResponse from(DiningReport report) { + return new DiningReportCreateResponse(report.getId(), DiningReportStatus.PENDING, report.getCreatedAt()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionRequest.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionRequest.java new file mode 100644 index 0000000000..f0cc7c84ce --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionRequest.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.domain.dining.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public record DiningReportDecisionRequest( + @Schema(description = "처리자 감사 정보. 서비스 토큰으로 인증하며 actor는 감사 기록에만 사용합니다.", requiredMode = REQUIRED) + @Valid @NotNull(message = "처리자 정보는 필수입니다.") DiningReportActor actor +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionResponse.java new file mode 100644 index 0000000000..270b19bfbe --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDecisionResponse.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +@JsonNaming(SnakeCaseStrategy.class) +public record DiningReportDecisionResponse( + @Schema(description = "대상 제보의 처리 결과. 재호출하면 최초 처리 정보를 유지합니다.", requiredMode = REQUIRED) + DiningReportResponse report, + @Schema(description = "처리 묶음에 속한 제보 ID 목록. 제보 ID 오름차순이며 재호출에서도 최초 목록을 반환합니다.", + example = "[1, 2]", requiredMode = REQUIRED) + List affectedReportIds, + @Schema(description = "기존 처리 결과를 반환했는지 여부. 새 처리이면 false, 같은 결과의 재호출이면 true입니다.", + example = "false", requiredMode = REQUIRED) + boolean alreadyProcessed +) { +} 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 new file mode 100644 index 0000000000..b61cc4f49b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResponse.java @@ -0,0 +1,53 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResultRequest.UUID_PATTERN; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonFormat; +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은 배정 시점의 값으로 고정됩니다.", + additionalProperties = Schema.AdditionalPropertiesValue.FALSE) +public record DiningReportDeliveryResponse( + @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초가 되는 한국시간. 이후 생성이나 수정 요청을 새로 시작하지 않습니다.", + 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) + 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 new file mode 100644 index 0000000000..354774829f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultRequest.java @@ -0,0 +1,215 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +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) +public record DiningReportDeliveryResultRequest( + @Schema(description = "작업 배정에서 받은 이번 시도의 비공개 토큰", format = "uuid", 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 +) { + 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}$"; + + @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()) { + throw CustomException.of(INVALID_REQUEST_BODY); + } + if (!value.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())) { + throw CustomException.of(INVALID_REQUEST_BODY); + } + } + for (String field : required) { + if (!value.hasNonNull(field)) { + throw CustomException.of(INVALID_REQUEST_BODY); + } + } + } + + private static String requiredText(JsonNode body, String field) { + JsonNode value = body.get(field); + if (value == null || value.isNull()) { + throw CustomException.of(INVALID_REQUEST_BODY); + } + if (!value.isTextual()) { + throw CustomException.of(NOT_READABLE_HTTP_MESSAGE); + } + if (value.textValue().isEmpty()) { + throw CustomException.of(INVALID_REQUEST_BODY); + } + 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 new file mode 100644 index 0000000000..3ede792262 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportDeliveryResultResponse.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.domain.dining.dto.DiningReportDeliveryResultRequest.UUID_PATTERN; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.UUID; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dining.model.DiningReportDeliveryStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "응답 시점의 현재 전송 상태. 제보의 status와 별개입니다.", + additionalProperties = Schema.AdditionalPropertiesValue.FALSE) +public record DiningReportDeliveryResultResponse( + @Schema(description = "전송 작업 UUID", format = "uuid", pattern = UUID_PATTERN, requiredMode = REQUIRED) + UUID deliveryId, + @Schema(description = "같은 결과를 재통보해도 현재 전송 상태를 반환합니다.", requiredMode = REQUIRED) + DiningReportDeliveryStatus deliveryState +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportPageResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportPageResponse.java new file mode 100644 index 0000000000..f27fe992eb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportPageResponse.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.common.model.Criteria; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record DiningReportPageResponse( + @Schema(description = "현재 페이지의 제보 목록. 조회 결과가 없으면 빈 배열입니다.", requiredMode = REQUIRED) + List reports, + @Schema(description = "조회 조건에 맞는 전체 제보 수", example = "2", minimum = "0", requiredMode = REQUIRED) + Integer totalCount, + @Schema(description = "현재 페이지의 제보 수", example = "2", minimum = "0", requiredMode = REQUIRED) + Integer currentCount, + @Schema(description = "전체 페이지 수. 조회 결과가 없어도 1입니다.", example = "1", minimum = "1", requiredMode = REQUIRED) + Integer totalPage, + @Schema(description = "보정된 현재 페이지 번호. 1부터 시작합니다.", example = "1", minimum = "1", requiredMode = REQUIRED) + Integer currentPage +) { + public static DiningReportPageResponse of(List reports, int total, Criteria criteria) { + return new DiningReportPageResponse<>(reports, total, reports.size(), + Math.max(1, (int)Math.ceil((double)total / criteria.getLimit())), criteria.getPage() + 1); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportResponse.java new file mode 100644 index 0000000000..44840cfcd2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportResponse.java @@ -0,0 +1,109 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static in.koreatech.koin.domain.dining.model.DiningReportProcessingType.MANUAL; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportProcessingType; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import in.koreatech.koin.domain.dining.model.DiningType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.ALWAYS) +@Schema(description = "삐봇용 제보 상세. 제보자 정보는 포함하지 않습니다.", example = """ + { + "report_id": 1, + "dining": {"id": 1, "date": "2026-10-02", "type": "LUNCH", "place": "A코너"}, + "status": "APPROVED", + "processing_type": "MANUAL", + "reason": "담당자 승인", + "created_at": "2026-10-02 12:00:00", + "processed_at": "2026-10-02 12:01:00", + "processor": {"workspace_id": "T0123456789", "user_id": "U0123456789", "display_name": "홍길동"}, + "image_url": "https://static.koreatech.in/upload/COOP/2026/10/2/e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg", + "processing_id": "550e8400-e29b-41d4-a716-446655440000", + "source_report_id": null, + "updated_at": "2026-10-02 12:01:00" + } + """) +public record DiningReportResponse( + @Schema(description = "제보 ID", example = "1", requiredMode = REQUIRED) + Integer reportId, + @Schema(description = "제보 대상 식단", requiredMode = REQUIRED) + DiningReference dining, + @Schema(description = "제보 상태. PENDING은 대기, APPROVED는 승인, REJECTED는 반려입니다.", + example = "APPROVED", requiredMode = REQUIRED) + DiningReportStatus status, + @Schema(description = "MANUAL은 담당자 처리, SAME_DINING_APPROVED는 같은 식단 승인에 따른 자동 승인, " + + "COOP_PREPROCESSED는 영양사 품절에 따른 자동 반려입니다. 대기 중이면 null입니다.", + example = "MANUAL", nullable = true, requiredMode = REQUIRED) + DiningReportProcessingType processingType, + @Schema(description = "처리 사유. 대기 중이면 null입니다.", example = "담당자 승인", nullable = true, requiredMode = REQUIRED) + String reason, + @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, + @Schema(description = "담당자 수동 처리 시각 (한국시간). 대기·자동 처리이면 null입니다.", example = "2026-10-02 12:01:00", + implementation = String.class, type = "string", pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", + nullable = true, requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime processedAt, + @Schema(description = "담당자 수동 처리의 감사 정보. 대기·자동 처리이면 null입니다.", nullable = true, requiredMode = REQUIRED) + DiningReportActor processor, + @Schema(description = "접수한 공용 COOP 업로드의 원본 사진 URL", + example = "https://static.koreatech.in/upload/COOP/2026/10/2/" + + "e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg", format = "uri", requiredMode = REQUIRED) + String imageUrl, + @Schema(description = "함께 처리한 제보가 공유하는 처리 묶음 UUID. 대기 중이면 null입니다.", + example = "550e8400-e29b-41d4-a716-446655440000", type = "string", format = "uuid", + nullable = true, requiredMode = REQUIRED) + UUID processingId, + @Schema(description = "같은 식단의 자동 승인을 발생시킨 제보 ID. SAME_DINING_APPROVED일 때만 값이 있고 그 외에는 null입니다.", + example = "1", nullable = true, requiredMode = REQUIRED) + Integer sourceReportId, + @Schema(description = "마지막 상태 변경 시각 (한국시간)", example = "2026-10-02 12:01: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 updatedAt +) { + public static DiningReportResponse from(DiningReport report) { + boolean manual = report.getProcessingType() == MANUAL; + DiningReportActor actor = manual + ? new DiningReportActor(report.getProcessorWorkspaceId(), report.getProcessorUserId(), + report.getProcessorName()) : null; + return new DiningReportResponse( + report.getId(), DiningReference.from(report.getDining()), report.getStatus(), + report.getProcessingType(), report.getReason(), report.getCreatedAt(), + manual ? report.getProcessedAt() : null, actor, report.getImageUrl(), report.getProcessingId(), + report.getSourceReportId(), report.getUpdatedAt() + ); + } + + public record DiningReference( + @Schema(description = "식단 ID", example = "1", requiredMode = REQUIRED) + Integer id, + @Schema(description = "식단 날짜 (yyyy-MM-dd)", example = "2026-10-02", + type = "string", format = "date", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") LocalDate date, + @Schema(description = "식사 구분. BREAKFAST는 아침, LUNCH는 점심, DINNER는 저녁입니다.", + example = "LUNCH", requiredMode = REQUIRED) + DiningType type, + @Schema(description = "식단 코스명", example = "A코너", requiredMode = REQUIRED) + String place + ) { + public static DiningReference from(Dining dining) { + return new DiningReference(dining.getId(), dining.getDate(), dining.getType(), dining.getPlace()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportSummaryResponse.java b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportSummaryResponse.java new file mode 100644 index 0000000000..c38f36645d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/dto/DiningReportSummaryResponse.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.dining.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportProcessingType; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.ALWAYS) +public record DiningReportSummaryResponse( + @Schema(description = "제보 ID", example = "1", requiredMode = REQUIRED) + Integer reportId, + @Schema(description = "제보 대상 식단", requiredMode = REQUIRED) + DiningReportResponse.DiningReference dining, + @Schema(description = "제보 상태. PENDING은 대기, APPROVED는 승인, REJECTED는 반려입니다.", + example = "APPROVED", requiredMode = REQUIRED) + DiningReportStatus status, + @Schema(description = "MANUAL은 담당자 처리, SAME_DINING_APPROVED는 같은 식단 승인에 따른 자동 승인, " + + "COOP_PREPROCESSED는 영양사 품절에 따른 자동 반려입니다. 대기 중이면 null입니다.", + example = "MANUAL", nullable = true, requiredMode = REQUIRED) + DiningReportProcessingType processingType, + @Schema(description = "처리 사유. 대기 중이면 null입니다.", example = "담당자 승인", nullable = true, requiredMode = REQUIRED) + String reason, + @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, + @Schema(description = "담당자 수동 처리 시각 (한국시간). 대기·자동 처리이면 null입니다.", example = "2026-10-02 12:01:00", + implementation = String.class, type = "string", pattern = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", + nullable = true, requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime processedAt, + @Schema(description = "담당자 수동 처리의 감사 정보. 대기·자동 처리이면 null입니다.", nullable = true, requiredMode = REQUIRED) + DiningReportActor processor +) { + public static DiningReportSummaryResponse from(DiningReport report) { + DiningReportResponse detail = DiningReportResponse.from(report); + return new DiningReportSummaryResponse(detail.reportId(), detail.dining(), detail.status(), + detail.processingType(), detail.reason(), detail.createdAt(), detail.processedAt(), detail.processor()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java b/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java index a2c5db2479..fcb6d06202 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java +++ b/src/main/java/in/koreatech/koin/domain/dining/model/Dining.java @@ -11,6 +11,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.hibernate.annotations.DynamicUpdate; + import in.koreatech.koin.common.model.BaseEntity; import in.koreatech.koin.global.exception.custom.KoinIllegalStateException; import jakarta.persistence.Column; @@ -27,6 +29,7 @@ @Getter @Entity +@DynamicUpdate @NoArgsConstructor(access = PROTECTED) @Table(name = "dining_menus", uniqueConstraints = { @UniqueConstraint( @@ -72,6 +75,10 @@ public class Dining extends BaseEntity { @Column(name = "sold_out", columnDefinition = "DATETIME") private LocalDateTime soldOut; + @Enumerated(STRING) + @Column(name = "sold_out_source", length = 16) + private DiningSoldOutSource soldOutSource; + @Column(name = "is_changed", columnDefinition = "DATETIME") private LocalDateTime isChanged; @@ -113,8 +120,18 @@ public void setSoldOut(LocalDateTime soldout) { this.soldOut = soldout; } + public boolean markSoldOut(LocalDateTime soldOutAt, DiningSoldOutSource source) { + if (soldOut != null) { + return false; + } + this.soldOut = soldOutAt; + this.soldOutSource = source; + return true; + } + public void cancelSoldOut() { this.soldOut = null; + this.soldOutSource = null; } public void likesDining() { 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 new file mode 100644 index 0000000000..886cbe2684 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReport.java @@ -0,0 +1,120 @@ +package in.koreatech.koin.domain.dining.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +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.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@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"}) +}) +public class DiningReport { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Integer id; + + @ManyToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "dining_id", nullable = false) + private Dining dining; + + @Column(name = "reporter_id") + private Integer reporterId; + + @Column(name = "image_url", length = 2048, nullable = false) + private String imageUrl; + + @Enumerated(STRING) + @Column(nullable = false, length = 16) + private DiningReportStatus status; + + @Enumerated(STRING) + @Column(name = "processing_type", length = 32) + private DiningReportProcessingType processingType; + + @Column(name = "processing_id", columnDefinition = "BINARY(16)") + private UUID processingId; + + @Column(name = "source_report_id") + private Integer sourceReportId; + + @Column(name = "processor_workspace_id", length = 64) + private String processorWorkspaceId; + + @Column(name = "processor_user_id", length = 64) + private String processorUserId; + + @Column(name = "processor_name", length = 80) + private String processorName; + + @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) { + 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; + return report; + } + + public void process(DiningReportStatus result, DiningReportProcessingType type, UUID batchId, + Integer sourceId, LocalDateTime now, String workspaceId, String userId, String displayName) { + if (status != DiningReportStatus.PENDING) { + throw new IllegalStateException("이미 처리된 제보는 변경할 수 없습니다."); + } + status = result; + processingType = type; + processingId = batchId; + sourceReportId = sourceId; + processedAt = now; + updatedAt = now; + processorWorkspaceId = workspaceId; + processorUserId = userId; + processorName = displayName; + } + + public String getReason() { + if (processingType == null) { + return null; + } + return switch (processingType) { + case MANUAL -> status == DiningReportStatus.APPROVED ? "담당자 승인" : "담당자 반려"; + case SAME_DINING_APPROVED -> "동일 코스 제보 승인에 따른 자동 처리"; + case COOP_PREPROCESSED -> "영양사 선처리로 제보 확인 없이 종료"; + }; + } +} 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 new file mode 100644 index 0000000000..b03c7ac5e2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportChange.java @@ -0,0 +1,61 @@ +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_change") +@NoArgsConstructor(access = PROTECTED) +public class DiningReportChange { + + public enum EventType { CREATED, PROCESSED } + + @Id + private Long sequence; + + @Column(name = "report_id", nullable = false) + private Integer reportId; + + @Enumerated(STRING) + @Column(name = "event_type", nullable = false, length = 16) + private EventType eventType; + + @Enumerated(STRING) + @Column(nullable = false, length = 16) + private DiningReportStatus status; + + @Enumerated(STRING) + @Column(name = "processing_type", length = 32) + private DiningReportProcessingType processingType; + + @Column(name = "processing_id", columnDefinition = "BINARY(16)") + private UUID processingId; + + @Column(name = "occurred_at", nullable = false, columnDefinition = "DATETIME") + private LocalDateTime occurredAt; + + public static DiningReportChange from(long sequence, DiningReport report, EventType eventType, + LocalDateTime now) { + DiningReportChange change = new DiningReportChange(); + change.sequence = sequence; + change.reportId = report.getId(); + change.eventType = eventType; + change.status = report.getStatus(); + change.processingType = report.getProcessingType(); + change.processingId = report.getProcessingId(); + change.occurredAt = now; + return change; + } +} 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 new file mode 100644 index 0000000000..eb399aecba --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDelivery.java @@ -0,0 +1,132 @@ +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 new file mode 100644 index 0000000000..09ba74f2ba --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryAttempt.java @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000000..f8bb4bd6f5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryFailureReason.java @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..0b6ca413a8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryMode.java @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..1e128ba985 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOperation.java @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..4142ee93ce --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryOutcome.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.dining.model; + +public enum DiningReportDeliveryOutcome { + SUCCEEDED, + NOT_APPLIED, + UNCERTAIN +} 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 new file mode 100644 index 0000000000..661de9b610 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryStatus.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.domain.dining.model; + +public enum DiningReportDeliveryStatus { + QUEUED, + IN_PROGRESS, + UNCERTAIN, + DELIVERED, + NEEDS_ATTENTION +} 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 new file mode 100644 index 0000000000..c596bc2d1c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportDeliveryTarget.java @@ -0,0 +1,115 @@ +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/DiningReportProcessingType.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportProcessingType.java new file mode 100644 index 0000000000..722c37d6c7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportProcessingType.java @@ -0,0 +1,5 @@ +package in.koreatech.koin.domain.dining.model; + +public enum DiningReportProcessingType { + MANUAL, SAME_DINING_APPROVED, COOP_PREPROCESSED +} 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 new file mode 100644 index 0000000000..6b8cec7674 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportSequence.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.domain.dining.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "dining_soldout_report_sequence") +@NoArgsConstructor(access = PROTECTED) +public class DiningReportSequence { + + @Id + @Column(columnDefinition = "TINYINT") + private Integer id; + + @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/model/DiningReportStatus.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportStatus.java new file mode 100644 index 0000000000..21648f1fbd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningReportStatus.java @@ -0,0 +1,5 @@ +package in.koreatech.koin.domain.dining.model; + +public enum DiningReportStatus { + PENDING, APPROVED, REJECTED +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/model/DiningSoldOutSource.java b/src/main/java/in/koreatech/koin/domain/dining/model/DiningSoldOutSource.java new file mode 100644 index 0000000000..5ff28c71e3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/model/DiningSoldOutSource.java @@ -0,0 +1,5 @@ +package in.koreatech.koin.domain.dining.model; + +public enum DiningSoldOutSource { + COOP, REPORT, UNKNOWN +} 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 new file mode 100644 index 0000000000..9d089f263d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportChangeRepository.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.dining.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.dining.model.DiningReportChange; + +public interface DiningReportChangeRepository extends Repository { + + DiningReportChange save(DiningReportChange change); + + List findBySequenceGreaterThanAndSequenceLessThanEqualOrderBySequenceAsc( + Long cursor, Long lastSequence, 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 new file mode 100644 index 0000000000..facafc1e5b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryAttemptRepository.java @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000000..c19978511a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryRepository.java @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000000..d26d125f93 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportDeliveryTargetRepository.java @@ -0,0 +1,27 @@ +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/repository/DiningReportRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java new file mode 100644 index 0000000000..840d147de2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportRepository.java @@ -0,0 +1,54 @@ +package in.koreatech.koin.domain.dining.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Lock; +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.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import jakarta.persistence.LockModeType; + +public interface DiningReportRepository extends Repository { + + DiningReport saveAndFlush(DiningReport report); + + Optional 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); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM DiningReport r WHERE r.dining.id = :diningId ORDER BY r.id") + List findAllByDiningIdForUpdate(@Param("diningId") Integer diningId); + + @Query(""" + SELECT COUNT(r) FROM DiningReport r + WHERE (:diningId IS NULL OR r.dining.id = :diningId) + AND (:processingId IS NULL OR r.processingId = :processingId) + AND (:status IS NULL OR r.status = :status) + """) + long countReports(@Param("diningId") Integer diningId, @Param("processingId") UUID processingId, + @Param("status") DiningReportStatus status); + + @Query(""" + SELECT r FROM DiningReport r JOIN FETCH r.dining + WHERE (:diningId IS NULL OR r.dining.id = :diningId) + AND (:processingId IS NULL OR r.processingId = :processingId) + AND (:status IS NULL OR r.status = :status) + """) + List findReports(@Param("diningId") Integer diningId, + @Param("processingId") UUID processingId, @Param("status") DiningReportStatus status, Pageable pageable); +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportSequenceRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportSequenceRepository.java new file mode 100644 index 0000000000..c0188a66a2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningReportSequenceRepository.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.dining.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.dining.model.DiningReportSequence; +import jakarta.persistence.LockModeType; + +public interface DiningReportSequenceRepository extends Repository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM DiningReportSequence s WHERE s.id = 1") + DiningReportSequence findForUpdate(); + + Optional findById(Integer id); +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java index d915d6619f..9cf6fe9c92 100644 --- a/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java +++ b/src/main/java/in/koreatech/koin/domain/dining/repository/DiningRepository.java @@ -7,8 +7,12 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.repository.query.Param; import org.springframework.data.repository.Repository; +import jakarta.persistence.LockModeType; + import in.koreatech.koin.domain.coop.exception.MenuNotFoundException; import in.koreatech.koin.domain.dining.model.Dining; import in.koreatech.koin.domain.dining.model.DiningType; @@ -19,6 +23,10 @@ public interface DiningRepository extends Repository { Optional findById(Integer id); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT d FROM Dining d WHERE d.id = :id") + Optional findByIdForUpdate(@Param("id") Integer id); + List findAllByDate(LocalDate date); default Dining getById(Integer id) { 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 new file mode 100644 index 0000000000..d551b73bfd --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportChangeService.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.dining.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportChange; +import in.koreatech.koin.domain.dining.model.DiningReportSequence; +import in.koreatech.koin.domain.dining.repository.DiningReportChangeRepository; +import in.koreatech.koin.domain.dining.repository.DiningReportSequenceRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class DiningReportChangeService { + + private final DiningReportSequenceRepository sequenceRepository; + private final DiningReportChangeRepository changeRepository; + private final DiningReportDeliveryService deliveryService; + + @Transactional(propagation = Propagation.MANDATORY) + public void append(List reports, DiningReportChange.EventType eventType, LocalDateTime now) { + if (reports.isEmpty()) { + return; + } + // Acquire this last and retain it until the business transaction commits. + DiningReportSequence sequence = Objects.requireNonNull(sequenceRepository.findForUpdate(), + "식단 제보 변경 순번 초기값이 없습니다."); + for (DiningReport report : reports) { + long sourceSequence = sequence.next(); + changeRepository.save(DiningReportChange.from(sourceSequence, report, eventType, now)); + deliveryService.recordChange(report, sourceSequence, eventType, now); + } + } +} 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 new file mode 100644 index 0000000000..887d8abffa --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportDeliveryService.java @@ -0,0 +1,371 @@ +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.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; + +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.DiningReportSequenceRepository; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +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 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(); + 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(); + } + 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)); + } + } + } + + // 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())) + .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); + } + } + 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); + } + } + 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; + } + return (target.getMessageTs() == null || Objects.equals(reference.messageTs(), target.getMessageTs())) + && (delivery.getConfirmedMessageTs() == null + || Objects.equals(reference.messageTs(), delivery.getConfirmedMessageTs())); + } + + 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 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 DiningReportResponse snapshot(String value) { + try { + return objectMapper.readValue(value, DiningReportResponse.class); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("저장된 제보 전송 내용을 읽을 수 없습니다.", exception); + } + } + + private String serialize(Object value) { + try { + return objectMapper.writeValueAsString(value); + } 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; + } + } + + private LocalDateTime now() { + return LocalDateTime.now(clock.withZone(KST)).truncatedTo(ChronoUnit.MICROS); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportQueryService.java b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportQueryService.java new file mode 100644 index 0000000000..e7f40e7547 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportQueryService.java @@ -0,0 +1,60 @@ +package in.koreatech.koin.domain.dining.service; + +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_DINING_REPORT; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.dining.dto.AdminDiningReportResponse; +import in.koreatech.koin.common.model.Criteria; +import in.koreatech.koin.domain.dining.dto.DiningReportPageResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportSummaryResponse; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import in.koreatech.koin.domain.dining.repository.DiningReportRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class DiningReportQueryService { + + private final DiningReportRepository reportRepository; + private final UserRepository userRepository; + + public DiningReportResponse getBotReport(Integer reportId) { + return DiningReportResponse.from(getReport(reportId)); + } + + public AdminDiningReportResponse getAdminReport(Integer reportId) { + DiningReport report = getReport(reportId); + User reporter = report.getReporterId() == null ? null + : userRepository.findById(report.getReporterId()).orElse(null); + return AdminDiningReportResponse.from(report, reporter); + } + + public DiningReportPageResponse getAdminReports( + boolean onlyPending, Integer page, Integer limit) { + DiningReportStatus status = onlyPending ? DiningReportStatus.PENDING : null; + int total = Math.toIntExact(reportRepository.countReports(null, null, status)); + Criteria criteria = Criteria.of(page, limit, total); + List rows = reportRepository.findReports(null, null, status, + PageRequest.of(criteria.getPage(), criteria.getLimit(), Sort.by(Sort.Direction.DESC, "createdAt", "id"))) + .stream().map(DiningReportSummaryResponse::from).toList(); + return DiningReportPageResponse.of(rows, total, criteria); + } + + private DiningReport getReport(Integer reportId) { + return reportRepository.findById(reportId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING_REPORT)); + } +} 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 new file mode 100644 index 0000000000..5c7f5a2009 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/dining/service/DiningReportService.java @@ -0,0 +1,222 @@ +package in.koreatech.koin.domain.dining.service; + +import static in.koreatech.koin.domain.dining.model.DiningReportChange.EventType.CREATED; +import static in.koreatech.koin.domain.dining.model.DiningReportChange.EventType.PROCESSED; +import static in.koreatech.koin.domain.dining.model.DiningReportProcessingType.COOP_PREPROCESSED; +import static in.koreatech.koin.domain.dining.model.DiningReportProcessingType.MANUAL; +import static in.koreatech.koin.domain.dining.model.DiningReportProcessingType.SAME_DINING_APPROVED; +import static in.koreatech.koin.domain.dining.model.DiningReportStatus.APPROVED; +import static in.koreatech.koin.domain.dining.model.DiningReportStatus.PENDING; +import static in.koreatech.koin.domain.dining.model.DiningReportStatus.REJECTED; +import static in.koreatech.koin.global.code.ApiResponseCode.*; + +import java.net.URI; +import java.time.Clock; +import java.time.LocalDateTime; +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; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import com.amazonaws.AmazonClientException; + +import in.koreatech.koin.common.event.DiningSoldOutEvent; +import in.koreatech.koin.domain.coop.exception.MenuNotFoundException; +import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.coopshop.model.CoopShopType; +import in.koreatech.koin.domain.coopshop.service.CoopShopService; +import in.koreatech.koin.domain.dining.dto.DiningReportActor; +import in.koreatech.koin.domain.dining.dto.DiningReportCreateRequest; +import in.koreatech.koin.domain.dining.dto.DiningReportCreateResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportDecisionResponse; +import in.koreatech.koin.domain.dining.dto.DiningReportResponse; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import in.koreatech.koin.domain.dining.model.DiningSoldOutSource; +import in.koreatech.koin.domain.dining.repository.DiningReportRepository; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.global.exception.CustomException; +import in.koreatech.koin.global.exception.custom.KoinIllegalStateException; +import in.koreatech.koin.infrastructure.s3.client.S3Client; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(isolation = Isolation.READ_COMMITTED) +@RequiredArgsConstructor +public class DiningReportService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final DiningRepository diningRepository; + private final DiningReportRepository reportRepository; + private final DiningReportChangeService changeService; + private final DiningSoldOutCacheRepository soldOutCacheRepository; + private final CoopShopService coopShopService; + private final ApplicationEventPublisher eventPublisher; + 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()); + } + + 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); + } + LocalDateTime now = now(); + if (!dining.getDate().equals(now.toLocalDate())) { + throw CustomException.of(DINING_REPORT_DATE_NOT_ALLOWED); + } + if (dining.getSoldOut() != null) { + throw CustomException.of(DINING_ALREADY_SOLD_OUT); + } + DiningReport report = DiningReport.create(dining, reporterId, request.imageUrl(), requestKey, 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); + } + throw exception; + } + changeService.append(List.of(report), CREATED, now); + return DiningReportCreateResponse.from(report); + } + + public DiningReportDecisionResponse approve(Integer reportId, DiningReportActor actor) { + return decide(reportId, actor, APPROVED); + } + + public DiningReportDecisionResponse reject(Integer reportId, DiningReportActor actor) { + return decide(reportId, actor, REJECTED); + } + + public void changeSoldOutByCoop(Integer diningId, boolean soldOut) { + Dining dining = diningRepository.findByIdForUpdate(diningId) + .orElseThrow(() -> MenuNotFoundException.withDetail("menuId: " + diningId)); + if (!soldOut) { + dining.cancelSoldOut(); + return; + } + List pending = reportRepository.findAllByDiningIdForUpdate(diningId).stream() + .filter(report -> report.getStatus() == PENDING).toList(); + LocalDateTime now = now(); + boolean changed = dining.markSoldOut(now, DiningSoldOutSource.COOP); + UUID batchId = UUID.randomUUID(); + for (DiningReport report : pending) { + report.process(REJECTED, COOP_PREPROCESSED, batchId, null, now, null, null, null); + } + if (changed) { + publishSoldOut(dining, now); + } + changeService.append(pending, PROCESSED, now); + } + + private DiningReportDecisionResponse decide(Integer reportId, DiningReportActor actor, + DiningReportStatus result) { + Integer diningId = reportRepository.findDiningIdById(reportId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING_REPORT)); + Dining dining = lockDining(diningId); + List reports = reportRepository.findAllByDiningIdForUpdate(diningId); + DiningReport target = reports.stream().filter(report -> report.getId().equals(reportId)).findFirst() + .orElseThrow(() -> CustomException.of(NOT_FOUND_DINING_REPORT)); + if (target.getStatus() != PENDING) { + if (target.getStatus() != result) { + throw CustomException.of(DINING_REPORT_ALREADY_PROCESSED); + } + List ids = reports.stream() + .filter(report -> Objects.equals(report.getProcessingId(), target.getProcessingId())) + .map(DiningReport::getId).toList(); + return new DiningReportDecisionResponse(DiningReportResponse.from(target), ids, true); + } + + LocalDateTime now = now(); + UUID batchId = UUID.randomUUID(); + List affected = result == APPROVED + ? reports.stream().filter(report -> report.getStatus() == PENDING).toList() : List.of(target); + for (DiningReport report : affected) { + boolean manual = report.getId().equals(reportId); + report.process(result, manual ? MANUAL : SAME_DINING_APPROVED, batchId, + manual ? null : reportId, now, manual ? actor.workspaceId() : null, + manual ? actor.userId() : null, manual ? actor.displayName() : null); + } + if (result == APPROVED && dining.markSoldOut(now, DiningSoldOutSource.REPORT)) { + publishSoldOut(dining, now); + } + changeService.append(affected, PROCESSED, now); + return new DiningReportDecisionResponse(DiningReportResponse.from(target), + 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)); + } + + private LocalDateTime now() { + return LocalDateTime.now(clock.withZone(KST)).withNano(0); + } + + private void validateImage(String imageUrl) { + if (imageUrl == null) { + throw CustomException.of(INVALID_REPORT_IMAGE); + } + String key; + try { + key = s3Client.extractKeyFromUrl(imageUrl); + // 업로드 키는 원문을 유지하고, 공백과 '%'는 URI 검증에만 이스케이프한다. + String validationUrl = s3Client.getDomainUrlPrefix() + key.replace("%", "%25").replace(" ", "%20"); + URI uri = URI.create(validationUrl); + if (!s3Client.isCustomDomainUrl(validationUrl) || uri.getQuery() != null || uri.getFragment() != null + || !key.startsWith("upload/COOP/") || key.endsWith("/")) { + throw CustomException.of(INVALID_REPORT_IMAGE); + } + } catch (IllegalArgumentException | KoinIllegalStateException exception) { + throw CustomException.of(INVALID_REPORT_IMAGE); + } + try { + if (!s3Client.doesFileExist(key)) { + throw CustomException.of(INVALID_REPORT_IMAGE); + } + } catch (AmazonClientException exception) { + throw CustomException.of(IMAGE_STORAGE_UNAVAILABLE); + } + } + + private void publishSoldOut(Dining dining, LocalDateTime now) { + if (dining.getDate().equals(now.toLocalDate()) + && coopShopService.getIsOpened(now, CoopShopType.CAFETERIA, dining.getType(), false) + && soldOutCacheRepository.findById(dining.getPlace()).isEmpty()) { + eventPublisher.publishEvent(new DiningSoldOutEvent(dining.getId(), dining.getPlace(), dining.getType())); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/CoopEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/CoopEventListener.java index 8c528dfbcb..202293f09c 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/CoopEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/CoopEventListener.java @@ -1,12 +1,18 @@ package in.koreatech.koin.domain.notification.eventlistener; +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; import in.koreatech.koin.common.event.DiningImageUploadEvent; import in.koreatech.koin.common.event.DiningSoldOutEvent; import in.koreatech.koin.domain.notification.service.CoopNotificationService; +import in.koreatech.koin.global.concurrent.exception.ConcurrencyLockException; import lombok.RequiredArgsConstructor; @Component @@ -14,11 +20,32 @@ @Profile("!test") public class CoopEventListener { + private static final long LOCK_WAIT_SECONDS = 7L; + private final CoopNotificationService coopNotificationService; + private final RedissonClient redissonClient; - @TransactionalEventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onDiningSoldOutRequest(DiningSoldOutEvent event) { - coopNotificationService.sendDiningSoldOutNotifications(event.id(), event.place(), event.diningType()); + String lockName = "lock:coop-dining-soldout-notification:" + event.place(); + RLock lock = redissonClient.getLock(lockName); + boolean acquired; + try { + // lease를 지정하지 않아 FCM 전송 중에는 watchdog이 락을 유지한다. + acquired = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw ConcurrencyLockException.withDetail("품절 처리 커밋 후 알림 락 대기 중 인터럽트 lockName: " + lockName); + } + if (!acquired) { + throw ConcurrencyLockException.withDetail("품절 처리 커밋 후 알림 락 대기 시간 초과 lockName: " + lockName); + } + try { + coopNotificationService.sendDiningSoldOutNotifications(event.id(), event.place(), event.diningType()); + } finally { + // REQUIRES_NEW 서비스 프록시의 알림 트랜잭션이 끝난 뒤 해제한다. + lock.unlock(); + } } @TransactionalEventListener diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/CoopNotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/CoopNotificationService.java index 62328d2ee2..7abd835e4a 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/CoopNotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/CoopNotificationService.java @@ -4,12 +4,22 @@ import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_IMAGE_UPLOAD; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import in.koreatech.koin.domain.coop.model.DiningSoldOutCache; +import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.coopshop.model.CoopShopType; +import in.koreatech.koin.domain.coopshop.service.CoopShopService; +import in.koreatech.koin.domain.dining.model.Dining; import in.koreatech.koin.domain.dining.model.DiningType; +import in.koreatech.koin.domain.dining.repository.DiningRepository; import in.koreatech.koin.domain.notification.model.Notification; import in.koreatech.koin.domain.notification.model.NotificationDetailSubscribeType; import in.koreatech.koin.domain.notification.model.NotificationFactory; @@ -24,8 +34,20 @@ public class CoopNotificationService { private final NotificationSubscribeRepository notificationSubscribeRepository; private final NotificationFactory notificationFactory; private final NotificationService notificationService; + private final DiningSoldOutCacheRepository diningSoldOutCacheRepository; + private final DiningRepository diningRepository; + private final CoopShopService coopShopService; + private final Clock clock; + @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendDiningSoldOutNotifications(Integer diningId, String place, DiningType diningType) { + Dining dining = diningRepository.getById(diningId); + LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + if (dining.getSoldOut() == null || !dining.getDate().equals(now.toLocalDate()) + || !coopShopService.getIsOpened(now, CoopShopType.CAFETERIA, diningType, false) + || diningSoldOutCacheRepository.findById(place).isPresent()) { + return; + } NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(diningType); List notifications = notificationSubscribeRepository.findAllBySubscribeTypeAndDetailType( DINING_SOLD_OUT, detailType) @@ -38,6 +60,7 @@ public void sendDiningSoldOutNotifications(Integer diningId, String place, Dinin )) .toList(); notificationService.pushNotifications(notifications); + diningSoldOutCacheRepository.save(DiningSoldOutCache.from(place)); } public void sendDiningImageUploadNotifications(int id, String imageUrl) { 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 f3df1e962b..32b269ed97 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -9,6 +9,19 @@ @RequiredArgsConstructor public enum ApiResponseCode { + DINING_REPORT_DATE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "오늘 식단만 제보할 수 있습니다."), + INVALID_REPORT_IMAGE(HttpStatus.BAD_REQUEST, "제보 사진이 올바르지 않습니다."), + 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, "전송 작업을 찾을 수 없습니다."), + 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, "이미지 저장소에 연결할 수 없습니다."), + /** * 2xx Success (성공) */ diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java index 4680951bac..c0da79981f 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java @@ -38,6 +38,11 @@ public OpenAPI openAPI() { .scheme("Bearer") .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") .bearerFormat("JWT") + ).addSecuritySchemes("Bot Service Authentication", new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("X-Koin-Service-Token") + .description("삐봇 서비스 토큰을 입력하여 인증을 활성화할 수 있습니다.") ); Server server = new Server(); server.setUrl(serverUrl); diff --git a/src/main/java/in/koreatech/koin/global/config/WebConfig.java b/src/main/java/in/koreatech/koin/global/config/WebConfig.java index a35b7eec9b..7d67b2bf5d 100644 --- a/src/main/java/in/koreatech/koin/global/config/WebConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/WebConfig.java @@ -16,6 +16,7 @@ import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import in.koreatech.koin.domain.dining.auth.DiningReportBotInterceptor; import in.koreatech.koin.global.auth.AuthArgumentResolver; import in.koreatech.koin.global.auth.ExtractAuthenticationInterceptor; import in.koreatech.koin.global.auth.UserIdArgumentResolver; @@ -36,6 +37,7 @@ @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final DiningReportBotInterceptor diningReportBotInterceptor; private final ExtractAuthenticationInterceptor extractAuthenticationInterceptor; private final WebCookieAuthenticationInterceptor webCookieAuthenticationInterceptor; private final IpAddressArgumentResolver ipAddressArgumentResolver; @@ -51,14 +53,19 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(diningReportBotInterceptor) + .addPathPatterns("/internal/dining/soldout-reports", "/internal/dining/soldout-reports/**") + .order(-1); registry.addInterceptor(extractAuthenticationInterceptor) .addPathPatterns("/**") - .excludePathPatterns("/v2/web/auth/**") + .excludePathPatterns("/v2/web/auth/**", "/internal/dining/soldout-reports", + "/internal/dining/soldout-reports/**") .order(0); registry.addInterceptor(webCookieAuthenticationInterceptor) .addPathPatterns("/**") .excludePathPatterns("/v2/users/login", "/user/login", "/user/refresh", "/user/logout", - "/student/login", "/owner/login", "/coop/login", "/admin/user/login") + "/student/login", "/owner/login", "/coop/login", "/admin/user/login", + "/internal/dining/soldout-reports", "/internal/dining/soldout-reports/**") .order(1); registry.addInterceptor(ipAddressInterceptor) .addPathPatterns("/**") diff --git a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java index eaf6f72f99..5ff8f43acd 100644 --- a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java @@ -50,7 +50,7 @@ @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { - private static final Set SENSITIVE_HEADERS = Set.of("authorization", "cookie", "x-csrf-token"); + private static final Set SENSITIVE_HEADERS = Set.of("authorization", "cookie", "x-csrf-token", "x-koin-service-token"); // 커스텀 예외 diff --git a/src/main/java/in/koreatech/koin/infrastructure/s3/client/S3Client.java b/src/main/java/in/koreatech/koin/infrastructure/s3/client/S3Client.java index 536a522594..88d0a073f2 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/s3/client/S3Client.java +++ b/src/main/java/in/koreatech/koin/infrastructure/s3/client/S3Client.java @@ -235,6 +235,10 @@ public String extractKeyFromUrl(String url) { return url.substring(domainUrlPrefix.length()); } + public boolean doesFileExist(String key) { + return amazonS3.doesObjectExist(bucketName, key); + } + public List extractKeysFromUrls(List urls) { return urls.stream() .map(this::extractKeyFromUrl) diff --git a/src/main/resources/db/migration/V12__create_dining_soldout_reports.sql b/src/main/resources/db/migration/V12__create_dining_soldout_reports.sql new file mode 100644 index 0000000000..4f63998991 --- /dev/null +++ b/src/main/resources/db/migration/V12__create_dining_soldout_reports.sql @@ -0,0 +1,78 @@ +ALTER TABLE dining_menus + ADD COLUMN sold_out_source VARCHAR(16) NULL, + ADD CONSTRAINT chk_dining_sold_out_source + CHECK (sold_out_source IS NULL OR sold_out_source IN ('COOP', 'REPORT', 'UNKNOWN')); + +CREATE TABLE dining_soldout_report ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + dining_id INT UNSIGNED NOT NULL, + reporter_id INT UNSIGNED NULL, + image_url VARCHAR(2048) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + processing_type VARCHAR(32) NULL, + processing_id BINARY(16) NULL, + source_report_id INT UNSIGNED NULL, + processor_workspace_id VARCHAR(64) NULL, + processor_user_id VARCHAR(64) NULL, + processor_name VARCHAR(80) NULL, + processed_at DATETIME NULL, + request_key BINARY(16) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_dining_report_student (reporter_id, dining_id), + UNIQUE KEY uk_dining_report_request (reporter_id, request_key), + KEY idx_dining_report_created (created_at, id), + KEY idx_dining_report_pending (status, created_at, id), + KEY idx_dining_report_dining (dining_id, status, id), + KEY idx_dining_report_processing (processing_id, id), + CONSTRAINT fk_dining_report_dining FOREIGN KEY (dining_id) REFERENCES dining_menus(id) ON DELETE RESTRICT, + CONSTRAINT fk_dining_report_reporter FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_dining_report_source FOREIGN KEY (source_report_id) REFERENCES dining_soldout_report(id) ON DELETE RESTRICT, + CONSTRAINT chk_dining_report_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')), + CONSTRAINT chk_dining_report_terminal CHECK ( + status = 'PENDING' OR (processing_type IS NOT NULL AND processing_id IS NOT NULL AND processed_at IS NOT NULL) + ), + CONSTRAINT chk_dining_report_result CHECK ( + (status = 'PENDING' AND processing_type IS NULL AND processing_id IS NULL AND processed_at IS NULL + AND source_report_id IS NULL AND processor_workspace_id IS NULL AND processor_user_id IS NULL AND processor_name IS NULL) + OR + (status IN ('APPROVED', 'REJECTED') AND processing_type = 'MANUAL' + AND processing_id IS NOT NULL AND processed_at IS NOT NULL AND source_report_id IS NULL + AND processor_workspace_id IS NOT NULL AND processor_user_id IS NOT NULL AND processor_name IS NOT NULL) + OR + (status = 'APPROVED' AND processing_type = 'SAME_DINING_APPROVED' + AND processing_id IS NOT NULL AND processed_at IS NOT NULL AND source_report_id IS NOT NULL + AND processor_workspace_id IS NULL AND processor_user_id IS NULL AND processor_name IS NULL) + OR + (status = 'REJECTED' AND processing_type = 'COOP_PREPROCESSED' + AND processing_id IS NOT NULL AND processed_at IS NOT NULL AND source_report_id IS NULL + AND processor_workspace_id IS NULL AND processor_user_id IS NULL AND processor_name IS NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE dining_soldout_report_change ( + sequence BIGINT NOT NULL, + report_id INT UNSIGNED NOT NULL, + event_type VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + processing_type VARCHAR(32) NULL, + processing_id BINARY(16) NULL, + occurred_at DATETIME NOT NULL, + PRIMARY KEY (sequence), + KEY idx_dining_report_change_report (report_id, sequence), + CONSTRAINT fk_dining_report_change_report FOREIGN KEY (report_id) REFERENCES dining_soldout_report(id) ON DELETE RESTRICT, + CONSTRAINT chk_dining_report_change_sequence CHECK (sequence > 0), + CONSTRAINT chk_dining_report_change_event CHECK (event_type IN ('CREATED', 'PROCESSED')), + CONSTRAINT chk_dining_report_change_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- The row lock is held until the business transaction commits or rolls back. +CREATE TABLE dining_soldout_report_sequence ( + id TINYINT NOT NULL, + last_sequence BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + CONSTRAINT chk_dining_report_sequence_id CHECK (id = 1), + CONSTRAINT chk_dining_report_sequence_value CHECK (last_sequence >= 0) +) ENGINE=InnoDB; +INSERT INTO dining_soldout_report_sequence (id, last_sequence) VALUES (1, 0); diff --git a/src/main/resources/db/migration/V13__create_dining_report_deliveries.sql b/src/main/resources/db/migration/V13__create_dining_report_deliveries.sql new file mode 100644 index 0000000000..1ce1be61ba --- /dev/null +++ b/src/main/resources/db/migration/V13__create_dining_report_deliveries.sql @@ -0,0 +1,117 @@ +ALTER TABLE dining_soldout_report_sequence + ADD COLUMN delivery_cooldown_until DATETIME(6) NULL; + +-- Existing reports are deliberately not backfilled: a historical Slack binding is unknown. +CREATE TABLE dining_soldout_report_delivery_target ( + report_id INT UNSIGNED NOT NULL, + desired_sequence BIGINT NOT NULL, + desired_snapshot LONGTEXT NOT NULL, + confirmed_sequence BIGINT NOT NULL DEFAULT 0, + workspace_id LONGTEXT NULL, + channel_id LONGTEXT NULL, + message_ts LONGTEXT NULL, + active_delivery_id BINARY(16) NULL, + hold_reason VARCHAR(16) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (report_id), + KEY idx_dining_delivery_target_work (hold_reason, report_id), + CONSTRAINT fk_dining_delivery_target_report FOREIGN KEY (report_id) + REFERENCES dining_soldout_report(id) ON DELETE RESTRICT, + CONSTRAINT fk_dining_delivery_target_change FOREIGN KEY (desired_sequence) + REFERENCES dining_soldout_report_change(sequence) ON DELETE RESTRICT, + CONSTRAINT chk_dining_delivery_target_sequence CHECK ( + desired_sequence > 0 AND confirmed_sequence >= 0 AND confirmed_sequence <= desired_sequence + ), + CONSTRAINT chk_dining_delivery_target_snapshot CHECK (JSON_VALID(desired_snapshot)), + CONSTRAINT chk_dining_delivery_target_routing CHECK ( + (workspace_id IS NULL AND channel_id IS NULL AND message_ts IS NULL) + OR (workspace_id IS NOT NULL AND CHAR_LENGTH(workspace_id) > 0 + AND channel_id IS NOT NULL AND CHAR_LENGTH(channel_id) > 0 + AND (message_ts IS NULL OR CHAR_LENGTH(message_ts) > 0)) + ), + CONSTRAINT chk_dining_delivery_target_hold CHECK ( + hold_reason IS NULL OR hold_reason IN ('LEGACY', 'REJECTED', 'CONFLICT') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE dining_soldout_report_delivery ( + id BINARY(16) NOT NULL, + report_id INT UNSIGNED NOT NULL, + source_sequence BIGINT NOT NULL, + report_snapshot LONGTEXT NOT NULL, + operation VARCHAR(16) NOT NULL, + workspace_id LONGTEXT NOT NULL, + channel_id LONGTEXT NOT NULL, + target_message_ts LONGTEXT NULL, + status VARCHAR(16) NOT NULL, + active_attempt_token BINARY(16) NULL, + send_attempt_token BINARY(16) NULL, + next_attempt_at DATETIME(6) NULL, + confirmed_message_ts LONGTEXT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_dining_delivery_snapshot (report_id, source_sequence), + CONSTRAINT fk_dining_delivery_target FOREIGN KEY (report_id) + REFERENCES dining_soldout_report_delivery_target(report_id) ON DELETE RESTRICT, + CONSTRAINT fk_dining_delivery_change FOREIGN KEY (source_sequence) + REFERENCES dining_soldout_report_change(sequence) ON DELETE RESTRICT, + CONSTRAINT chk_dining_delivery_snapshot CHECK (JSON_VALID(report_snapshot)), + CONSTRAINT chk_dining_delivery_sequence CHECK (source_sequence > 0), + CONSTRAINT chk_dining_delivery_operation CHECK ( + (operation = 'CREATE' AND target_message_ts IS NULL) + OR (operation = 'UPDATE' AND target_message_ts IS NOT NULL AND CHAR_LENGTH(target_message_ts) > 0) + ), + CONSTRAINT chk_dining_delivery_routing CHECK ( + CHAR_LENGTH(workspace_id) > 0 AND CHAR_LENGTH(channel_id) > 0 + AND (confirmed_message_ts IS NULL OR CHAR_LENGTH(confirmed_message_ts) > 0) + ), + CONSTRAINT chk_dining_delivery_status CHECK ( + status IN ('QUEUED', 'IN_PROGRESS', 'UNCERTAIN', 'DELIVERED', 'NEEDS_ATTENTION') + ), + CONSTRAINT chk_dining_delivery_active CHECK ( + (status = 'IN_PROGRESS' AND active_attempt_token IS NOT NULL AND send_attempt_token IS NOT NULL + AND next_attempt_at IS NULL) + OR (status <> 'IN_PROGRESS' AND active_attempt_token IS NULL) + ), + CONSTRAINT chk_dining_delivery_confirmed CHECK ( + status <> 'DELIVERED' OR (confirmed_message_ts IS NOT NULL AND next_attempt_at IS NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE dining_soldout_report_delivery_attempt ( + token BINARY(16) NOT NULL, + delivery_id BINARY(16) NOT NULL, + mode VARCHAR(16) NOT NULL, + send_attempt_token BINARY(16) NOT NULL, + issued_at DATETIME(6) NOT NULL, + expires_at DATETIME(6) NOT NULL, + expired_at DATETIME(6) NULL, + invalidated_at DATETIME(6) NULL, + accepted_outcome VARCHAR(16) NULL, + accepted_result LONGTEXT NULL, + original_result LONGTEXT NULL, + evidence_history LONGTEXT NOT NULL, + result_at DATETIME(6) NULL, + PRIMARY KEY (token), + KEY idx_dining_delivery_attempt_origin (delivery_id, send_attempt_token, mode), + CONSTRAINT fk_dining_delivery_attempt_delivery FOREIGN KEY (delivery_id) + REFERENCES dining_soldout_report_delivery(id) ON DELETE RESTRICT, + CONSTRAINT chk_dining_delivery_attempt_mode CHECK (mode IN ('SEND', 'VERIFY')), + CONSTRAINT chk_dining_delivery_attempt_origin CHECK (mode <> 'SEND' OR send_attempt_token = token), + CONSTRAINT chk_dining_delivery_attempt_deadline CHECK (expires_at = DATE_ADD(issued_at, INTERVAL 60 SECOND)), + CONSTRAINT chk_dining_delivery_attempt_outcome CHECK ( + accepted_outcome IS NULL OR accepted_outcome IN ('SUCCEEDED', 'NOT_APPLIED', 'UNCERTAIN') + ), + CONSTRAINT chk_dining_delivery_attempt_verify CHECK (mode <> 'VERIFY' OR accepted_outcome <> 'NOT_APPLIED'), + CONSTRAINT chk_dining_delivery_attempt_result CHECK ( + (accepted_outcome IS NULL AND accepted_result IS NULL AND result_at IS NULL) + OR (accepted_outcome IS NOT NULL AND accepted_result IS NOT NULL AND result_at IS NOT NULL) + ), + CONSTRAINT chk_dining_delivery_attempt_evidence CHECK ( + JSON_VALID(evidence_history) + AND (original_result IS NULL OR JSON_VALID(original_result)) + AND (accepted_result IS NULL OR JSON_VALID(accepted_result)) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningApiTest.java index 709526ee02..9c617ab766 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/DiningApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningApiTest.java @@ -7,13 +7,23 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +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.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import org.junit.jupiter.api.BeforeAll; 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.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; import org.springframework.http.MediaType; import in.koreatech.koin.acceptance.AcceptanceTest; @@ -28,8 +38,20 @@ import in.koreatech.koin.domain.dining.repository.DiningRepository; import in.koreatech.koin.domain.user.model.User; +@Import(DiningApiTest.DiningClockConfig.class) class DiningApiTest extends AcceptanceTest { + @TestConfiguration(proxyBeanMethods = false) + static class DiningClockConfig { + + @Bean + @Primary + Clock diningApiTestFixedClock() { + ZoneId zoneId = ZoneId.of("Asia/Seoul"); + return Clock.fixed(LocalDateTime.of(2024, 1, 15, 12, 0).atZone(zoneId).toInstant(), zoneId); + } + } + @Autowired private DiningRepository diningRepository; @@ -264,6 +286,59 @@ void setUp() { .andReturn(); } + @ParameterizedTest + @ValueSource(strings = {"{\"menu_id\": %d}", "{\"menu_id\": %d, \"sold_out\": null}"}) + void 품절_여부가_누락되거나_null이면_기존_품절_상태를_유지한다(String requestBody) throws Exception { + Dining dining = diningRepository.getById(A코너_점심.getId()); + LocalDateTime soldOutAt = LocalDateTime.now(clock).minusMinutes(5); + dining.setSoldOut(soldOutAt); + entityManager.flush(); + entityManager.clear(); + assertThat(diningRepository.getById(dining.getId()).getSoldOut()).isEqualTo(soldOutAt); + + mockMvc.perform( + patch("/coop/dining/soldout") + .header("Authorization", "Bearer " + token_준기) + .content(requestBody.formatted(dining.getId())) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("sold_out")); + + entityManager.flush(); + entityManager.clear(); + assertThat(diningRepository.getById(dining.getId()).getSoldOut()).isEqualTo(soldOutAt); + } + + @Test + void 품절_여부를_false로_명시하면_기존_품절_상태를_해제한다() throws Exception { + Dining dining = diningRepository.getById(A코너_점심.getId()); + LocalDateTime soldOutAt = LocalDateTime.now(clock).minusMinutes(5); + dining.setSoldOut(soldOutAt); + entityManager.flush(); + entityManager.clear(); + assertThat(diningRepository.getById(dining.getId()).getSoldOut()).isEqualTo(soldOutAt); + + mockMvc.perform( + patch("/coop/dining/soldout") + .header("Authorization", "Bearer " + token_준기) + .content(""" + { + "menu_id": %d, + "sold_out": false + } + """.formatted(dining.getId())) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(content().string("")); + + entityManager.flush(); + entityManager.clear(); + assertThat(diningRepository.getById(dining.getId()).getSoldOut()).isNull(); + } + @Test void 해당_식사시간에_품절_요청을_한다_품절_알림이_발송된다() throws Exception { mockMvc.perform( diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java new file mode 100644 index 0000000000..5091baece0 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningReportDeliveryApiTest.java @@ -0,0 +1,629 @@ +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.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.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +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", + "dining.report.delivery.workspace-id=T_EXAMPLE", + "dining.report.delivery.channel-id=C_EXAMPLE" +}) +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 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; + + @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 -> { + // 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(); + 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 본문과_멱등키_없는_claim은_서비스_인증과_빈_응답을_구분한다() 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); + 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")))) + .andExpect(status().isUnauthorized()); + } + + @Test + void CREATE_성공_재통보는_문자열_ts를_보존하고_제보를_승인하지_않는다() 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); + assertThat(reportStatus(reportId)).isEqualTo("PENDING"); + assertThat(jdbcTemplate.queryForObject("SELECT sold_out FROM dining_menus WHERE id = ?", + Object.class, diningId)).isNull(); + 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 { + 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"); + emptyClaim(); + } + + @Test + void 배정_뒤_승인해도_VERIFY는_원본을_확인하고_완료_후_새_UPDATE를_배정한다() throws Exception { + int reportId = submit(diningId); + JsonNode send = nextClaim(); + JsonNode approved = approve(reportId).path("report"); + 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); + 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); + emptyClaim(); + advance(1); + assertSameWork(send, nextClaim(), "VERIFY"); + } + + @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); + emptyClaim(); + result(send, success(MESSAGE_TS)).andExpect(status().isOk()) + .andExpect(jsonPath("$.delivery_state").value("DELIVERED")); + } + + @ParameterizedTest + @ValueSource(strings = {"NOT_SENT", "RATE_LIMITED", "REJECTED"}) + void VERIFY_만료_후_늦은_SEND_미반영_증거는_원인에_맞게_복구한다(String reason) throws Exception { + submit(diningId); + JsonNode send = nextClaim(); + advance(60); + JsonNode verify = nextClaim(); + advance(60); + 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"); + } + } + + @Test + void 요청제한은_다른_제보에도_적용되고_더_긴_대기와_재통보를_보존한다() 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); + 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); + } + advance(600); + emptyClaim(); + assertThat(reportStatus(reportId)).isEqualTo("APPROVED"); + } + + @Test + void 결과는_엄격한_본문_변형과_시도_소유권을_검증한다() throws Exception { + submit(diningId); + JsonNode task = nextClaim(); + 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)), + "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))); + for (var invalid : invalidBodies.entrySet()) { + for (var request : invalid.getValue()) { + result(task, 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()); + } + + @Test + void 동시_claim은_한_제보에_SEND를_한번만_배정한다() 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(); + emptyClaim(); + result(task, success(MESSAGE_TS)).andExpect(status().isOk()); + emptyClaim(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void 동시_성공_증거는_같으면_멱등이고_다르면_연결을_보존하며_보류한다(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()); + 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); + 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); + } + } + + 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, 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 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 Map success(String messageTs) { + return Map.of("outcome", "SUCCEEDED", "message_ref", Map.of("channel_id", "C_EXAMPLE", "message_ts", messageTs)); + } + + 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 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 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 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()); + } + + 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/DiningSoldOutNotificationConcurrencyTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutNotificationConcurrencyTest.java new file mode 100644 index 0000000000..bc577c7590 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutNotificationConcurrencyTest.java @@ -0,0 +1,245 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.notification.model.NotificationDetailSubscribeType.LUNCH; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; +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.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.DataType; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +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.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.DiningAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.common.event.DiningSoldOutEvent; +import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.notification.eventlistener.CoopEventListener; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.CoopNotificationService; +import in.koreatech.koin.infrastructure.fcm.FcmClient; +import in.koreatech.koin.infrastructure.fcm.FcmSendRequest; +import in.koreatech.koin.infrastructure.fcm.FcmSendResponse; + +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import(DiningSoldOutNotificationConcurrencyTest.NotificationLockConfig.class) +class DiningSoldOutNotificationConcurrencyTest extends AcceptanceTest { + + private static final long TIMEOUT_SECONDS = 15; + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private DiningAcceptanceFixture diningFixture; + + @Autowired + private CoopShopAcceptanceFixture coopShopFixture; + + @Autowired + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Autowired + private DiningSoldOutCacheRepository cacheRepository; + + @Autowired + private CoopNotificationService notificationService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private RedissonClient redissonClient; + + @MockBean + private FcmClient fcmClient; + + private Integer diningId; + private Integer otherDiningId; + private String coopToken; + + @BeforeEach + void setUp() { + clear(); + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transaction.executeWithoutResult(status -> { + coopShopFixture.현재학기(); + entityManager.flush(); + jdbcTemplate.update(""" + UPDATE coop_opens SET open_time = '00:00', close_time = '23:59' WHERE type = '점심' + """); + coopToken = userFixture.getToken(userFixture.준기_영양사().getUser()); + var recipient = userFixture.준호_학생(departmentFixture.컴퓨터공학부(), null).getUser(); + recipient.permitNotification("dining-soldout-concurrency-device-token"); + notificationSubscribeRepository.save(NotificationSubscribe.builder() + .user(recipient).subscribeType(DINING_SOLD_OUT).build()); + notificationSubscribeRepository.save(NotificationSubscribe.builder() + .user(recipient).subscribeType(DINING_SOLD_OUT).detailType(LUNCH).build()); + diningId = diningFixture.A코너_점심(LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))).getId(); + otherDiningId = diningFixture.B코너_점심(LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))).getId(); + }); + } + + @AfterEach + void tearDown() { + clear(); + } + + @Test + void FCM_6초_지연에도_같은_장소는_한번만_전송하고_다른_장소는_독립적으로_진행한다() throws Exception { + CountDownLatch firstSendEntered = new CountDownLatch(1); + CountDownLatch releaseFirstSend = new CountDownLatch(1); + CountDownLatch committedEvents = new CountDownLatch(2); + CountDownLatch completedNotifications = new CountDownLatch(3); + AtomicLong firstSendThread = new AtomicLong(); + ExecutorService executor = Executors.newFixedThreadPool(3); + CoopEventListener realListener = new CoopEventListener(notificationService, redissonClient); + RLock placeLock = redissonClient.getLock("lock:coop-dining-soldout-notification:A코너"); + try { + // 공통 test profile의 listener mock을 실제 listener 메서드로 연결한다. + // 실제 HTTP 트랜잭션의 AFTER_COMMIT과 RedisHash 캐시는 그대로 사용한다. + doAnswer(invocation -> { + DiningSoldOutEvent event = invocation.getArgument(0); + if (event.place().equals("A코너")) { + committedEvents.countDown(); + } + realListener.onDiningSoldOutRequest(event); + completedNotifications.countDown(); + return null; + }).when(coopEventListener).onDiningSoldOutRequest(any(DiningSoldOutEvent.class)); + doAnswer(invocation -> { + List requests = invocation.getArgument(0); + if (requests.get(0).title().startsWith("A코너") + && firstSendThread.compareAndSet(0, Thread.currentThread().getId())) { + firstSendEntered.countDown(); + if (!releaseFirstSend.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("첫 FCM 전송 해제 대기 시간이 초과되었습니다."); + } + } + return List.of(FcmSendResponse.succeeded()); + }).when(fcmClient).sendMessages(anyList()); + + Future first = executor.submit(() -> changeSoldOut(diningId, true)); + assertThat(firstSendEntered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + assertThat(jdbcTemplate.queryForObject("SELECT sold_out_source FROM dining_menus WHERE id = ?", + String.class, diningId)).isEqualTo("COOP"); + assertThat(cacheRepository.findById("A코너")).isEmpty(); + + Future second = executor.submit(() -> { + assertThat(changeSoldOut(diningId, false)).isEqualTo(200); + return changeSoldOut(diningId, true); + }); + assertThat(committedEvents.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + Future other = executor.submit(() -> changeSoldOut(otherDiningId, true)); + // A의 FCM을 6초 이상 붙잡아 기본 5초 lease를 잘못 지정하면 실패한다. + assertThatThrownBy(() -> second.get(6, TimeUnit.SECONDS)).isInstanceOf(TimeoutException.class); + assertThat(placeLock.isHeldByThread(firstSendThread.get())).isTrue(); + assertThat(cacheRepository.findById("A코너")).isEmpty(); + // 다른 장소는 A의 락 해제 전에 HTTP, 알림, 캐시 저장을 모두 완료해야 한다. + assertThat(other.isDone()).isTrue(); + assertThat(other.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isEqualTo(200); + assertThat(cacheRepository.findById("B코너")).isPresent(); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification", Integer.class)).isEqualTo(1); + releaseFirstSend.countDown(); + assertThat(first.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isEqualTo(200); + assertThat(second.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isEqualTo(200); + // AFTER_COMMIT 예외가 HTTP 200과 함께 로그로만 남아도 성공으로 오인하지 않는다. + assertThat(completedNotifications.getCount()).isZero(); + verify(coopEventListener, times(3)).onDiningSoldOutRequest(any(DiningSoldOutEvent.class)); + verify(fcmClient, times(2)).sendMessages(anyList()); + verify(fcmClient).sendMessages(argThat(requests -> requests.size() == 1 + && requests.get(0).title().startsWith("A코너"))); + verify(fcmClient).sendMessages(argThat(requests -> requests.size() == 1 + && requests.get(0).title().startsWith("B코너"))); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification", Integer.class)).isEqualTo(2); + assertThat(cacheRepository.findById("A코너")).isPresent(); + assertThat(redisTemplate.type("diningSoldOut:A코너")).isEqualTo(DataType.HASH); + assertThat(redisTemplate.getExpire("diningSoldOut:A코너", TimeUnit.SECONDS)).isBetween(7190L, 7200L); + assertThat(redisTemplate.type("diningSoldOut:B코너")).isEqualTo(DataType.HASH); + assertThat(redisTemplate.getExpire("diningSoldOut:B코너", TimeUnit.SECONDS)).isBetween(7190L, 7200L); + assertThat(placeLock.isLocked()).isFalse(); + assertThat(redissonClient.getLock("lock:coop-dining-soldout-notification:B코너").isLocked()).isFalse(); + } finally { + releaseFirstSend.countDown(); + executor.shutdownNow(); + try { + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } finally { + reset(coopEventListener, fcmClient); + } + } + } + + private int changeSoldOut(Integer targetDiningId, boolean soldOut) throws Exception { + return mockMvc.perform(patch("/coop/dining/soldout") + .header("Authorization", "Bearer " + coopToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"menu_id\":%d,\"sold_out\":%s}".formatted(targetDiningId, soldOut))) + .andReturn().getResponse().getStatus(); + } + + @TestConfiguration + static class NotificationLockConfig { + + @Bean(destroyMethod = "shutdown") + RedissonClient notificationRedissonClient(@Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port) { + Config config = new Config(); + // 테스트에서 watchdog 갱신도 6초 지연 안에 검증한다. 운영 설정은 변경하지 않는다. + config.setLockWatchdogTimeout(3_000); + config.useSingleServer().setAddress("redis://" + host + ":" + port); + return Redisson.create(config); + } + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java new file mode 100644 index 0000000000..f31e331bf7 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportApiTest.java @@ -0,0 +1,520 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.anyString; +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.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.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; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.ResultActions; + +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; +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.model.Dining; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; +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" +}) +class DiningSoldOutReportApiTest extends AcceptanceTest { + + private static final String BOT_PATH = "/internal/dining/soldout-reports"; + 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"; + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private DiningAcceptanceFixture diningFixture; + + @Autowired + private CoopShopAcceptanceFixture coopShopFixture; + + @Autowired + private DiningRepository diningRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private S3Client s3Client; + + private User student; + private String studentToken; + private String otherStudentToken; + private String thirdStudentToken; + private String generalToken; + private String coopToken; + private String adminToken; + private Dining dining; + private Dining otherDining; + + @BeforeEach + void setUp() { + clear(); + // clear()가 지운 운영 마이그레이션의 sequence seed만 복원한다. + entityManager.createNativeQuery( + "INSERT INTO dining_soldout_report_sequence (id, last_sequence) VALUES (1, 0)").executeUpdate(); + coopShopFixture.현재학기(); + var department = departmentFixture.컴퓨터공학부(); + student = userFixture.준호_학생(department, null).getUser(); + studentToken = userFixture.getToken(student); + User otherStudent = userFixture.성빈_학생(department).getUser(); + otherStudentToken = userFixture.getToken(otherStudent); + // 기존 두 학생 fixture의 익명 닉네임 UNIQUE 충돌을 피한다. + entityManager.createNativeQuery("UPDATE users SET anonymous_nickname = '품절제보_성빈' WHERE id = :id") + .setParameter("id", otherStudent.getId()).executeUpdate(); + thirdStudentToken = userFixture.getToken(userFixture.익명_학생(department).getUser()); + coopToken = userFixture.getToken(userFixture.준기_영양사().getUser()); + adminToken = userFixture.getToken(userFixture.코인_운영자().getUser()); + generalToken = userFixture.getToken(userRepository.save(User.builder() + .loginPw(student.getLoginPw()) + .userType(UserType.GENERAL) + .isAuthed(true) + .isDeleted(false) + .build())); + LocalDate today = LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))); + dining = diningFixture.A코너_점심(today); + otherDining = diningFixture.B코너_점심(today); + 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); + } + + @Test + void 접수는_판매상태를_유지하고_재시도와_중복을_구분한다() throws Exception { + String key = UUID.randomUUID().toString(); + int id = body(submit(studentToken, dining, key, 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()); + } + + @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(null, dining, key, 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, dining, key, "https://outside.example/soldout.jpg").andExpect(status().isBadRequest()); + when(s3Client.doesFileExist(anyString())).thenReturn(false); + submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isBadRequest()); + student.updateAuthenticationStatus(false); + entityManager.flush(); + submit(studentToken, dining, key, IMAGE_URL).andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_STUDENT")); + } + + @Test + void 반려는_대상만_승인은_남은_대기를_처리하고_재처리는_최초결과를_유지한다() throws Exception { + int rejectedId = createReport(studentToken, dining); + int selectedId = createReport(otherStudentToken, dining); + int automaticId = createReport(thirdStudentToken, dining); + int unrelatedId = createReport(studentToken, otherDining); + JsonNode rejected = body(decide(rejectedId, "reject", "U_FIRST").andExpect(status().isOk()) + .andExpect(jsonPath("$.processing_id").doesNotHaveJsonPath()) + .andExpect(jsonPath("$.report.processing_id").isNotEmpty()) + .andExpect(jsonPath("$.affected_report_ids").value(contains(rejectedId)))); + JsonNode replayedRejection = body(decide(rejectedId, "reject", "U_OTHER").andExpect(status().isOk()) + .andExpect(jsonPath("$.already_processed").value(true)) + .andExpect(jsonPath("$.report.processing_id") + .value(rejected.path("report").path("processing_id").asText())) + .andExpect(jsonPath("$.affected_report_ids").value(contains(rejectedId)))); + assertThat(replayedRejection.path("report")).isEqualTo(rejected.path("report")); + botDetail(selectedId).andExpect(jsonPath("$.status").value("PENDING")); + assertThat(storedDining().getSoldOut()).isNull(); + JsonNode approved = body(decide(selectedId, "approve", "U_FIRST").andExpect(status().isOk()) + .andExpect(jsonPath("$.processing_id").doesNotHaveJsonPath()) + .andExpect(jsonPath("$.report.processing_id").isNotEmpty()) + .andExpect(jsonPath("$.report.status").value("APPROVED")) + .andExpect(jsonPath("$.report.processing_type").value("MANUAL")) + .andExpect(jsonPath("$.affected_report_ids").value(contains(selectedId, automaticId)))); + String group = approved.path("report").path("processing_id").asText(); + botDetail(automaticId).andExpect(jsonPath("$.status").value("APPROVED")) + .andExpect(jsonPath("$.processing_id").value(group)) + .andExpect(jsonPath("$.processing_type").value("SAME_DINING_APPROVED")) + .andExpect(jsonPath("$.source_report_id").value(selectedId)); + decide(automaticId, "approve", "U_OTHER").andExpect(status().isOk()) + .andExpect(jsonPath("$.already_processed").value(true)) + .andExpect(jsonPath("$.report.processing_id").value(group)) + .andExpect(jsonPath("$.affected_report_ids").value(contains(selectedId, automaticId))) + .andExpect(jsonPath("$.report.processing_type").value("SAME_DINING_APPROVED")) + .andExpect(jsonPath("$.report.source_report_id").value(selectedId)) + .andExpect(jsonPath("$.report.reason").value("동일 코스 제보 승인에 따른 자동 처리")) + .andExpect(jsonPath("$.report.processor").value(nullValue())) + .andExpect(jsonPath("$.report.processed_at").value(nullValue())) + .andExpect(jsonPath("$.report.reporter").doesNotExist()); + decide(automaticId, "reject", "U_OTHER").andExpect(status().isConflict()); + botDetail(rejectedId).andExpect(jsonPath("$.status").value("REJECTED")); + botDetail(unrelatedId).andExpect(jsonPath("$.status").value("PENDING")); + assertThat(storedDining().getSoldOut()).isNotNull(); + decide(selectedId, "approve", "U_OTHER").andExpect(status().isOk()) + .andExpect(jsonPath("$.already_processed").value(true)) + .andExpect(jsonPath("$.report.processing_id").value(group)) + .andExpect(jsonPath("$.report.processor.user_id").value("U_FIRST")) + .andExpect(jsonPath("$.report.processor.display_name").value("U_FIRST")); + decide(selectedId, "reject", "U_OTHER").andExpect(status().isConflict()); + decide(rejectedId, "approve", "U_OTHER").andExpect(status().isConflict()); + botDetail(selectedId).andExpect(jsonPath("$.processing_id").value(group)); + } + + @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 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()) + .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); + decide(selectedId, "approve", "U_OTHER").andExpect(status().isOk()) + .andExpect(jsonPath("$.already_processed").value(true)) + .andExpect(jsonPath("$.report.processing_id").value(previousGroup)) + .andExpect(jsonPath("$.affected_report_ids").value(contains(selectedId, automaticId))); + assertThat(storedDining().getSoldOut()).isNull(); + botDetail(newId).andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.processing_id").value(nullValue())) + .andExpect(jsonPath("$.reporter").doesNotExist()); + JsonNode processed = body(decide(newId, "approve", "U_OTHER").andExpect(status().isOk()) + .andExpect(jsonPath("$.affected_report_ids").value(contains(newId)))); + assertThat(processed.path("report").path("processing_id").asText()).isNotEqualTo(previousGroup); + decide(selectedId, "approve", "U_OTHER").andExpect(status().isOk()) + .andExpect(jsonPath("$.already_processed").value(true)) + .andExpect(jsonPath("$.report.processing_id").value(previousGroup)) + .andExpect(jsonPath("$.affected_report_ids").value(contains(selectedId, automaticId))); + for (int id : List.of(selectedId, automaticId)) { + botDetail(id).andExpect(jsonPath("$.status").value("APPROVED")) + .andExpect(jsonPath("$.processing_id").value(previousGroup)); + } + } + + @Test + void 영양사_선처리는_대기만_반려하고_해제후에도_재제보를_막는다() throws Exception { + int rejectedId = createReport(studentToken, dining); + int firstId = createReport(otherStudentToken, dining); + int secondId = createReport(thirdStudentToken, dining); + JsonNode rejected = body(decide(rejectedId, "reject", "U_FIRST").andExpect(status().isOk())); + setSoldOut(true); + assertThat(storedDining().getSoldOut()).isNotNull(); + for (int id : List.of(firstId, secondId)) { + botDetail(id).andExpect(jsonPath("$.status").value("REJECTED")) + .andExpect(jsonPath("$.processing_type").value("COOP_PREPROCESSED")) + .andExpect(jsonPath("$.reason").value("영양사 선처리로 제보 확인 없이 종료")); + } + botDetail(rejectedId).andExpect(jsonPath("$.status").value("REJECTED")) + .andExpect(jsonPath("$.processing_type").value("MANUAL")) + .andExpect(jsonPath("$.processing_id") + .value(rejected.path("report").path("processing_id").asText())); + setSoldOut(false); + assertThat(storedDining().getSoldOut()).isNull(); + botDetail(firstId).andExpect(jsonPath("$.status").value("REJECTED")); + submit(otherStudentToken, dining, UUID.randomUUID().toString(), IMAGE_URL).andExpect(status().isConflict()); + } + + @Test + void 봇과_회원_인증을_분리하고_제보자는_관리자에게만_공개한다() throws Exception { + int id = createReport(studentToken, dining); + mockMvc.perform(get(BOT_PATH + "/{id}", id)).andExpect(status().isUnauthorized()); + mockMvc.perform(get(BOT_PATH + "/{id}", id).header(BOT_HEADER, "wrong-token")) + .andExpect(status().isUnauthorized()); + for (String token : List.of(studentToken, adminToken)) { + mockMvc.perform(get(BOT_PATH + "/{id}", id).header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post(BOT_PATH + "/{id}/approve", id).header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + botDetail(id).andExpect(jsonPath("$.reporter").doesNotExist()); + mockMvc.perform(get(ADMIN_PATH + "/{id}", id).header(BOT_HEADER, BOT_TOKEN)) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get(ADMIN_PATH + "/{id}", id).header(BOT_HEADER, BOT_TOKEN) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isForbidden()); + mockMvc.perform(get(ADMIN_PATH + "/{id}", id).header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.reporter.id").value(student.getId())); + } + + @Test + void 관리자_목록은_최신순으로_미처리를_필터한다() throws Exception { + int firstId = createReport(studentToken, dining); + int rejectedId = createReport(otherStudentToken, dining); + int latestId = createReport(studentToken, otherDining); + decide(rejectedId, "reject", "U_FIRST").andExpect(status().isOk()); + mockMvc.perform(get(ADMIN_PATH).header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reports[*].report_id").value(contains(latestId, rejectedId, firstId))); + mockMvc.perform(get(ADMIN_PATH).header("Authorization", "Bearer " + adminToken).param("only_pending", "true")) + .andExpect(status().isOk()) + .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")); + 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"); + } + + 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()); + JsonNode response = contentSchema(api, operation.at("/responses/200/content")); + assertClosedObject(response, "delivery_id", "delivery_state"); + assertUuid(response.path("properties").path("delivery_id")); + } + + 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) + .content(objectMapper.writeValueAsString(Map.of("image_url", imageUrl))); + if (token != null) { + request.header("Authorization", "Bearer " + token); + } + return mockMvc.perform(request); + } + + private int createReport(String token, Dining target) throws Exception { + return body(submit(token, target, UUID.randomUUID().toString(), IMAGE_URL) + .andExpect(status().isCreated())).path("report_id").asInt(); + } + + private ResultActions decide(int id, String decision, String actor) throws Exception { + return mockMvc.perform(post(BOT_PATH + "/{id}/{decision}", id, decision).header(BOT_HEADER, BOT_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("actor", + Map.of("workspace_id", "T_DINING", "user_id", actor, "display_name", actor))))); + } + + private ResultActions botDetail(int id) throws Exception { + return mockMvc.perform(get(BOT_PATH + "/{id}", id).header(BOT_HEADER, BOT_TOKEN)).andExpect(status().isOk()); + } + + private void setSoldOut(boolean soldOut) throws Exception { + mockMvc.perform(patch("/coop/dining/soldout").header("Authorization", "Bearer " + coopToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("menu_id", dining.getId(), "sold_out", soldOut)))) + .andExpect(status().isOk()); + } + + private JsonNode body(ResultActions result) throws Exception { + return objectMapper.readTree(result.andReturn().getResponse().getContentAsByteArray()); + } + + private Dining storedDining() { + entityManager.flush(); + entityManager.clear(); + return diningRepository.getById(dining.getId()); + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java new file mode 100644 index 0000000000..b688f0c14a --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/DiningSoldOutReportConcurrencyTest.java @@ -0,0 +1,484 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.notification.model.NotificationDetailSubscribeType.LUNCH; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import java.time.LocalDate; +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; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +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.CsvSource; +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.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +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.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.DiningAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.common.event.DiningSoldOutEvent; +import in.koreatech.koin.domain.coop.dto.SoldOutRequest; +import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.coop.service.CoopService; +import in.koreatech.koin.domain.dining.dto.DiningReportActor; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.repository.DiningReportRepository; +import in.koreatech.koin.domain.dining.repository.DiningReportSequenceRepository; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.dining.service.DiningReportService; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.CoopNotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.infrastructure.fcm.FcmClient; +import in.koreatech.koin.infrastructure.fcm.FcmSendResponse; +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" +}) +class DiningSoldOutReportConcurrencyTest extends AcceptanceTest { + + private static final int CONCURRENCY = 2; + private static final long TIMEOUT_SECONDS = 10; + private static final String IMAGE_DOMAIN = "https://test.koreatech.in/"; + private static final String IMAGE_KEY = + "upload/COOP/2024/1/15/e924c7d3-3757-4cd0-961b-e65c1f6cc8ad/soldout.jpg"; + private static final String IMAGE_URL = IMAGE_DOMAIN + IMAGE_KEY; + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private DiningAcceptanceFixture diningFixture; + + @Autowired + private CoopShopAcceptanceFixture coopShopFixture; + + @Autowired + private DiningRepository diningRepository; + + @Autowired + private CoopService coopService; + + @Autowired + private DiningReportService diningReportService; + + @Autowired + private DiningSoldOutCacheRepository diningSoldOutCacheRepository; + + @Autowired + private CoopNotificationService coopNotificationService; + + @Autowired + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @MockBean + private S3Client s3Client; + + @MockBean + private FcmClient fcmClient; + + @SpyBean + private DiningReportRepository reportRepository; + + @SpyBean + private DiningReportSequenceRepository sequenceRepository; + + private TransactionTemplate transactionTemplate; + private Integer diningId; + private Integer firstStudentId; + private String firstStudentToken; + private String secondStudentToken; + + @BeforeEach + void setUp() { + clear(); + transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transactionTemplate.setTimeout((int)TIMEOUT_SECONDS); + transactionTemplate.executeWithoutResult(status -> { + // DBInitializer.clear()가 삭제한 마이그레이션 seed를 테스트 fixture에서만 복원한다. + jdbcTemplate.update("INSERT INTO dining_soldout_report_sequence (id, last_sequence) VALUES (1, 0)"); + coopShopFixture.현재학기(); + entityManager.flush(); + // JVM 시간대와 무관하게 고정 시각의 품절 알림 경로를 실행한다. + jdbcTemplate.update(""" + UPDATE coop_opens SET open_time = '00:00', close_time = '23:59' WHERE type = '점심' + """); + var department = departmentFixture.컴퓨터공학부(); + var firstStudent = userFixture.준호_학생(department, null).getUser(); + firstStudentId = firstStudent.getId(); + firstStudentToken = userFixture.getToken(firstStudent); + secondStudentToken = userFixture.getToken(userFixture.성빈_학생(department).getUser()); + diningId = diningFixture.A코너_점심(LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))).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); + clearInvocations(coopEventListener); + } + + @AfterEach + void tearDown() { + clear(); + } + + @Test + void 서로_다른_학생과_식단의_빈_잠금조회_후_동시_접수는_모두_성공한다() throws Exception { + Integer secondDiningId = transactionTemplate.execute(status -> + diningFixture.B코너_점심(LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))).getId()); + CountDownLatch beforeInsert = new CountDownLatch(CONCURRENCY); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENCY); + // Spring Data 인터페이스 spy의 원래 delegate로 실제 INSERT를 실행한다. + var originalAnswer = mockingDetails(reportRepository).getMockCreationSettings().getDefaultAnswer(); + try { + doAnswer(invocation -> { + beforeInsert.countDown(); + if (!beforeInsert.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("두 제보의 빈 잠금조회 완료를 기다리다 시간 초과했습니다."); + } + return originalAnswer.answer(invocation); + }).when(reportRepository).saveAndFlush(any(DiningReport.class)); + + // HTTP 밖에 트랜잭션을 열면 create()의 READ_COMMITTED를 덮어쓰므로 직접 요청한다. + Future first = executor.submit(() -> submit(firstStudentToken, diningId)); + Future second = executor.submit(() -> submit(secondStudentToken, secondDiningId)); + assertThat(List.of(first.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + second.get(TIMEOUT_SECONDS, TimeUnit.SECONDS))).containsExactly(201, 201); + assertThat(beforeInsert.getCount()).isZero(); + List> reports = jdbcTemplate.queryForList(""" + SELECT id, dining_id, reporter_id, status FROM dining_soldout_report ORDER BY id + """); + assertThat(reports).hasSize(2); + assertThat(reports).extracting(row -> ((Number)row.get("dining_id")).intValue()) + .containsExactlyInAnyOrder(diningId, secondDiningId); + assertThat(reports).extracting(row -> ((Number)row.get("reporter_id")).intValue()) + .contains(firstStudentId).doesNotHaveDuplicates(); + assertThat(reports).extracting(row -> row.get("status")).containsOnly("PENDING"); + assertThat(jdbcTemplate.queryForList(""" + SELECT report_id FROM dining_soldout_report_change + WHERE event_type = 'CREATED' AND status = 'PENDING' ORDER BY sequence + """, Integer.class)) + .containsExactlyInAnyOrderElementsOf(reports.stream() + .map(row -> ((Number)row.get("id")).intValue()).toList()); + } finally { + while (beforeInsert.getCount() > 0) { + beforeInsert.countDown(); + } + executor.shutdownNow(); + try { + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } finally { + reset(reportRepository); + } + } + } + + @ParameterizedTest(name = "{0}: 서로 다른 식단의 제보 처리와 변경 피드가 모두 커밋된다") + @CsvSource({ + "approve, APPROVED, MANUAL", + "reject, REJECTED, MANUAL", + "coop, REJECTED, COOP_PREPROCESSED" + }) + void 서로_다른_식단의_처리는_제보와_변경순번_잠금이_엇갈려도_모두_커밋된다( + String action, String expectedStatus, String expectedProcessingType) throws Exception { + // Flyway를 끈 Hibernate 스키마에 V12 보조 인덱스와 source FK용 인덱스를 복원한다. + Map> indexes = Map.of( + "idx_dining_report_created", List.of("created_at", "id"), + "idx_dining_report_pending", List.of("status", "created_at", "id"), + "idx_dining_report_dining", List.of("dining_id", "status", "id"), + "idx_dining_report_processing", List.of("processing_id", "id"), + "fk_dining_report_source", List.of("source_report_id")); + String indexColumnsSql = """ + SELECT column_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'dining_soldout_report' + AND index_name = ? + ORDER BY seq_in_index + """; + for (var index : indexes.entrySet()) { + if (jdbcTemplate.queryForList(indexColumnsSql, String.class, index.getKey()).isEmpty()) { + jdbcTemplate.execute("CREATE INDEX `" + index.getKey() + "` ON dining_soldout_report (" + + String.join(", ", index.getValue()) + ")"); + } + assertThat(jdbcTemplate.queryForList(indexColumnsSql, String.class, index.getKey())) + .containsExactlyElementsOf(index.getValue()); + } + // 복합 인덱스가 FK를 충족한 뒤, 운영에 없는 Hibernate dining_id 단일 인덱스를 제거한다. + List singleDiningIndexes = jdbcTemplate.queryForList(""" + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'dining_soldout_report' AND non_unique = 1 + GROUP BY index_name + HAVING COUNT(*) = 1 AND MIN(column_name) = 'dining_id' + """, String.class); + for (String index : singleDiningIndexes) { + jdbcTemplate.execute("DROP INDEX `" + index.replace("`", "``") + "` ON dining_soldout_report"); + } + assertThat(jdbcTemplate.queryForList(""" + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) + FROM information_schema.statistics + 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", + "status,created_at,id", "dining_id,status,id", "processing_id,id", "source_report_id"); + + Integer secondDiningId = transactionTemplate.execute(status -> + diningFixture.B코너_점심(LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul")))).getId()); + assertThat(submit(firstStudentToken, diningId)).isEqualTo(201); + assertThat(submit(secondStudentToken, secondDiningId)).isEqualTo(201); + Integer firstReportId = jdbcTemplate.queryForObject( + "SELECT id FROM dining_soldout_report WHERE dining_id = ?", Integer.class, diningId); + Integer secondReportId = jdbcTemplate.queryForObject( + "SELECT id FROM dining_soldout_report WHERE dining_id = ?", Integer.class, secondDiningId); + long sequenceBefore = lastSequence(); + CountDownLatch firstAtSequence = new CountDownLatch(1); + CountDownLatch secondLockedSequence = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENCY); + var originalAnswer = mockingDetails(sequenceRepository).getMockCreationSettings().getDefaultAnswer(); + try { + doAnswer(invocation -> { + if (firstAtSequence.getCount() > 0) { + // T1의 D1 제보 잠금을 유지한 채 T2가 실제 sequence 잠금을 먼저 얻도록 한다. + firstAtSequence.countDown(); + if (!secondLockedSequence.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("두 번째 식단의 변경순번 잠금을 기다리다 시간 초과했습니다."); + } + return originalAnswer.answer(invocation); + } + var sequence = originalAnswer.answer(invocation); + secondLockedSequence.countDown(); + return sequence; + }).when(sequenceRepository).findForUpdate(); + + // 외부 트랜잭션 없이 각 서비스의 실제 isolation과 커밋 시점의 UPDATE를 검증한다. + Future first = executor.submit(() -> process(action, diningId, firstReportId)); + assertThat(firstAtSequence.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + Future second = executor.submit(() -> process(action, secondDiningId, secondReportId)); + first.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + second.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(secondLockedSequence.getCount()).isZero(); + + List> reports = jdbcTemplate.queryForList( + "SELECT status, processing_type FROM dining_soldout_report ORDER BY id"); + assertThat(reports).hasSize(2); + assertThat(reports).extracting(row -> row.get("status")).containsOnly(expectedStatus); + assertThat(reports).extracting(row -> row.get("processing_type")).containsOnly(expectedProcessingType); + List> changes = jdbcTemplate.queryForList(""" + SELECT sequence, report_id, status, processing_type FROM dining_soldout_report_change + WHERE event_type = 'PROCESSED' ORDER BY sequence + """); + assertThat(changes).extracting(row -> ((Number)row.get("report_id")).intValue()) + .containsExactly(secondReportId, firstReportId); + assertThat(changes).extracting(row -> ((Number)row.get("sequence")).longValue()) + .containsExactly(sequenceBefore + 1, sequenceBefore + 2); + 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 + """); + assertThat(targets).extracting(row -> ((Number)row.get("report_id")).intValue()) + .containsExactly(secondReportId, firstReportId); + assertThat(targets).extracting(row -> ((Number)row.get("desired_sequence")).longValue()) + .containsExactly(sequenceBefore + 1, sequenceBefore + 2); + assertThat(targets).extracting(row -> row.get("status")).containsOnly(expectedStatus); + } finally { + firstAtSequence.countDown(); + secondLockedSequence.countDown(); + executor.shutdownNow(); + try { + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } finally { + reset(sequenceRepository); + } + } + } + + @Test + void 영양사_품절_롤백은_제보와_변경피드와_캐시를_되돌리고_다음_커밋의_알림을_막지_않는다() throws Exception { + assertThat(submit(firstStudentToken, diningId)).isEqualTo(201); + assertThat(submit(secondStudentToken, diningId)).isEqualTo(201); + String deviceToken = "test-dining-soldout-device-token"; + transactionTemplate.executeWithoutResult(status -> { + User recipient = entityManager.find(User.class, firstStudentId); + recipient.permitNotification(deviceToken); + notificationSubscribeRepository.save(NotificationSubscribe.builder() + .user(recipient).subscribeType(DINING_SOLD_OUT).build()); + notificationSubscribeRepository.save(NotificationSubscribe.builder() + .user(recipient).subscribeType(DINING_SOLD_OUT).detailType(LUNCH).build()); + }); + try { + when(fcmClient.sendMessages(anyList())).thenReturn(List.of(FcmSendResponse.succeeded())); + doAnswer(invocation -> { + DiningSoldOutEvent event = invocation.getArgument(0); + coopNotificationService.sendDiningSoldOutNotifications(event.id(), event.place(), event.diningType()); + return null; + }).when(coopEventListener).onDiningSoldOutRequest(any(DiningSoldOutEvent.class)); + + List> pending = reportRows(); + List> changesBefore = changeRows(); + List> targetsBefore = targetRows(); + long sequenceBefore = lastSequence(); + Dining dining = diningRepository.getById(diningId); + assertThat(dining.getSoldOut()).isNull(); + assertThat(diningSoldOutCacheRepository.findById(dining.getPlace())).isEmpty(); + + transactionTemplate.executeWithoutResult(status -> { + coopService.changeSoldOut(new SoldOutRequest(diningId, true)); + entityManager.flush(); + assertThat(diningRepository.getById(diningId).getSoldOut()).isNotNull(); + assertThat(reportRows()).extracting(row -> row.get("status")).containsExactly("REJECTED", "REJECTED"); + assertThat(changeRows()).filteredOn(row -> "PROCESSED".equals(row.get("event_type"))).hasSize(2); + assertThat(lastSequence()).isEqualTo(sequenceBefore + 2); + status.setRollbackOnly(); + }); + + assertThat(diningRepository.getById(diningId).getSoldOut()).isNull(); + assertThat(jdbcTemplate.queryForObject("SELECT sold_out_source FROM dining_menus WHERE id = ?", + String.class, diningId)).isNull(); + assertThat(reportRows()).isEqualTo(pending); + assertThat(changeRows()).isEqualTo(changesBefore); + assertThat(targetRows()).isEqualTo(targetsBefore); + assertThat(lastSequence()).isEqualTo(sequenceBefore); + assertThat(diningSoldOutCacheRepository.findById(dining.getPlace())).isEmpty(); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification", Integer.class)).isZero(); + verify(coopEventListener, never()).onDiningSoldOutRequest(any(DiningSoldOutEvent.class)); + verifyNoInteractions(fcmClient); + + coopService.changeSoldOut(new SoldOutRequest(diningId, true)); + + assertThat(diningRepository.getById(diningId).getSoldOut()).isNotNull(); + List> rejected = reportRows(); + assertThat(rejected).hasSize(2); + assertThat(rejected).extracting(row -> row.get("status")).containsOnly("REJECTED"); + assertThat(rejected).extracting(row -> row.get("processing_type")).containsOnly("COOP_PREPROCESSED"); + assertThat(rejected.get(0).get("processing_id")).isNotNull(); + assertThat(rejected).extracting(row -> row.get("processing_id")) + .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()) + .containsExactly(sequenceBefore + 1, sequenceBefore + 2); + verify(coopEventListener).onDiningSoldOutRequest( + new DiningSoldOutEvent(diningId, dining.getPlace(), dining.getType())); + verify(fcmClient).sendMessages(argThat(requests -> requests.size() == 1 + && deviceToken.equals(requests.get(0).targetDeviceToken()))); + assertThat(diningSoldOutCacheRepository.findById(dining.getPlace())).isPresent(); + assertThat(jdbcTemplate.queryForList("SELECT users_id, app_path FROM notification")) + .singleElement().satisfies(row -> { + assertThat(((Number)row.get("users_id")).intValue()).isEqualTo(firstStudentId); + assertThat(row).containsEntry("app_path", "DINING"); + }); + assertThat(jdbcTemplate.queryForObject("SELECT is_push_success FROM notification", Boolean.class)).isTrue(); + } finally { + reset(coopEventListener, fcmClient); + } + } + + private void process(String action, Integer targetDiningId, Integer reportId) { + DiningReportActor actor = new DiningReportActor("T0123456789", "U0123456789", "홍길동"); + switch (action) { + case "approve" -> diningReportService.approve(reportId, actor); + case "reject" -> diningReportService.reject(reportId, actor); + case "coop" -> coopService.changeSoldOut(new SoldOutRequest(targetDiningId, true)); + default -> throw new IllegalArgumentException("알 수 없는 제보 처리: " + action); + } + } + + 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(); + } + + 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 + FROM dining_soldout_report + WHERE dining_id = ? + ORDER BY id + """, diningId); + } + + private List> changeRows() { + return jdbcTemplate.queryForList(""" + SELECT c.sequence, c.report_id, c.event_type, c.status, c.processing_type, + HEX(c.processing_id) AS processing_id, c.occurred_at + 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); + } + + private long lastSequence() { + return jdbcTemplate.queryForObject( + "SELECT last_sequence FROM dining_soldout_report_sequence WHERE id = 1", Long.class); + } + + private List> targetRows() { + 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 + """, 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 new file mode 100644 index 0000000000..a4aada64ec --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/migration/DiningSoldOutReportMigrationTest.java @@ -0,0 +1,192 @@ +package in.koreatech.koin.acceptance.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import org.flywaydb.core.Flyway; +import org.hibernate.SessionFactory; +import org.hibernate.cfg.Configuration; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +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; + +@Testcontainers +class DiningSoldOutReportMigrationTest { + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0.29") + .withDatabaseName("dining_soldout_report_migration") + .withUsername("test") + .withPassword("test"); + + @Test + void V11부터_V13까지_기존_이력을_보존하고_전송_스키마와_제약을_검증한다() throws SQLException { + migrateTo("11"); + try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { + statement.executeUpdate(""" + INSERT INTO users (id, password, user_type, anonymous_nickname) + VALUES (101, 'test', 'STUDENT', '기존학생') + """); + statement.executeUpdate("INSERT INTO students (user_id, student_number) VALUES (101, '2025100101')"); + statement.executeUpdate(""" + INSERT INTO dining_menus (id, date, type, place, menu, sold_out) + VALUES (201, '2026-10-02', 'LUNCH', 'A코너', '기존 메뉴', '2026-10-02 12:30:00') + """); + } + migrateTo("12"); + // V13 이전 제보는 메시지 연결을 알 수 없으므로 자동 CREATE 대상으로 이관하지 않는다. + try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { + statement.executeUpdate(""" + INSERT INTO dining_soldout_report + (id, reporter_id, dining_id, image_url, request_key, created_at, updated_at, status, + processing_type, processing_id, processed_at, + processor_workspace_id, processor_user_id, processor_name) + VALUES (301, 101, 201, 'https://example.com/report.jpg', UNHEX(REPEAT('01', 16)), + '2026-10-02 12:35:00', '2026-10-02 12:40:00', 'APPROVED', + 'MANUAL', UNHEX(REPEAT('02', 16)), '2026-10-02 12:40:00', 'workspace', 'processor', '담당자') + """); + statement.executeUpdate(""" + INSERT INTO dining_soldout_report_change + (sequence, report_id, event_type, status, processing_type, processing_id, occurred_at) + 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"); + } + 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(); + 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(); + + 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') + """); + 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') + """); + 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') + """); + 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', '[]') + """); + 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 + """)).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"); + 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"); + 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"); + + assertThat(queryInt(statement, "SELECT COUNT(*) FROM users WHERE id = 101")).isZero(); + 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)) + """)).isOne(); + assertThat(queryInt(statement, """ + SELECT COUNT(*) FROM dining_soldout_report_change WHERE report_id = 301 AND sequence = 1 + AND event_type = 'PROCESSED' AND status = 'APPROVED' AND processing_type = 'MANUAL' + AND processing_id = UNHEX(REPEAT('02', 16)) + """)).isOne(); + } + } + + private static void migrateTo(String version) { + Flyway.configure() + .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration") + .target(version) + .load() + .migrate(); + } + + private static Connection getConnection() throws SQLException { + return DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + } + + private int queryInt(Statement statement, String query) throws SQLException { + try (ResultSet result = statement.executeQuery(query)) { + assertThat(result.next()).isTrue(); + return result.getInt(1); + } + } +} 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 new file mode 100644 index 0000000000..45532509ba --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/dining/service/DiningReportImageValidationTest.java @@ -0,0 +1,157 @@ +package in.koreatech.koin.unit.domain.dining.service; + +import static in.koreatech.koin.global.code.ApiResponseCode.DINING_REPORT_DATE_NOT_ALLOWED; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REPORT_IMAGE; +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +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; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; + +import com.amazonaws.services.s3.AmazonS3; + +import in.koreatech.koin.domain.coop.repository.DiningSoldOutCacheRepository; +import in.koreatech.koin.domain.coopshop.service.CoopShopService; +import in.koreatech.koin.domain.dining.dto.DiningReportCreateRequest; +import in.koreatech.koin.domain.dining.model.Dining; +import in.koreatech.koin.domain.dining.model.DiningReport; +import in.koreatech.koin.domain.dining.model.DiningReportStatus; +import in.koreatech.koin.domain.dining.model.DiningType; +import in.koreatech.koin.domain.dining.repository.DiningReportRepository; +import in.koreatech.koin.domain.dining.repository.DiningRepository; +import in.koreatech.koin.domain.dining.service.DiningReportChangeService; +import in.koreatech.koin.domain.dining.service.DiningReportService; +import in.koreatech.koin.global.exception.CustomException; +import in.koreatech.koin.infrastructure.s3.client.S3Client; +import in.koreatech.koin.infrastructure.s3.dto.UploadUrlRequest; +import in.koreatech.koin.infrastructure.s3.model.ImageUploadDomain; +import in.koreatech.koin.infrastructure.s3.service.UploadService; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +class DiningReportImageValidationTest { + + private static final String BUCKET_NAME = "test-bucket"; + private static final String DOMAIN_URL = "https://static.koreatech.in/"; + private final Clock clock = Clock.fixed(Instant.parse("2026-10-02T03:00:00Z"), ZoneId.of("Asia/Seoul")); + + private AmazonS3 amazonS3; + private S3Client s3Client; + private DiningRepository diningRepository; + private DiningReportRepository reportRepository; + private DiningReportService service; + + @BeforeEach + void setUp() { + amazonS3 = mock(AmazonS3.class); + s3Client = new S3Client(BUCKET_NAME, DOMAIN_URL, S3Presigner.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("test-access-key", "test-secret-key"))) + .region(Region.AP_NORTHEAST_2), amazonS3, clock); + diningRepository = mock(DiningRepository.class); + reportRepository = mock(DiningReportRepository.class); + service = new DiningReportService(diningRepository, reportRepository, + mock(DiningReportChangeService.class), mock(DiningSoldOutCacheRepository.class), + mock(CoopShopService.class), mock(ApplicationEventPublisher.class), s3Client, clock); + } + + @ParameterizedTest + @ValueSource(strings = {"soldout.jpg", "soldout photo.jpg", "품절.jpg", "soldout+photo.jpg", + "soldout%20photo.jpg", "100%.jpg"}) + void 공용_COOP_업로드의_파일명과_URL을_원문으로_접수한다(String fileName) { + String imageUrl = new UploadService(s3Client, clock).getPresignedUrl(ImageUploadDomain.COOP, + new UploadUrlRequest(1, "image/jpeg", fileName)).fileUrl(); + String rawKey = imageUrl.substring(DOMAIN_URL.length()); + assertThat(imageUrl).startsWith(DOMAIN_URL + "upload/COOP/2026/10/2/").endsWith("/" + fileName); + when(amazonS3.doesObjectExist(BUCKET_NAME, rawKey)).thenReturn(true); + + 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)); + + ArgumentCaptor reportCaptor = ArgumentCaptor.forClass(DiningReport.class); + verify(reportRepository).saveAndFlush(reportCaptor.capture()); + assertThat(response.status()).isEqualTo(DiningReportStatus.PENDING); + assertThat(reportCaptor.getValue().getImageUrl()).isEqualTo(imageUrl); + verify(amazonS3).doesObjectExist(BUCKET_NAME, rawKey); + } + + @ParameterizedTest + @CsvSource({ + "2026-10-02T14:59:59Z, 2026-10-02, true", + "2026-10-02T15:00:00Z, 2026-10-02, false", + "2026-10-02T15:00:00Z, 2026-10-03, true" + }) + void KST_자정_전후에는_한국날짜의_당일_식단만_접수한다(String instant, String diningDate, boolean allowed) { + Clock utcClock = Clock.fixed(Instant.parse(instant), ZoneOffset.UTC); + DiningReportService boundaryService = new DiningReportService(diningRepository, reportRepository, + mock(DiningReportChangeService.class), mock(DiningSoldOutCacheRepository.class), + mock(CoopShopService.class), mock(ApplicationEventPublisher.class), s3Client, utcClock); + String imageUrl = new UploadService(s3Client, clock).getPresignedUrl(ImageUploadDomain.COOP, + new UploadUrlRequest(1, "image/jpeg", "soldout.jpg")).fileUrl(); + when(amazonS3.doesObjectExist(BUCKET_NAME, imageUrl.substring(DOMAIN_URL.length()))).thenReturn(true); + 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)) + .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); + assertThat(response.status()).isEqualTo(DiningReportStatus.PENDING); + assertThat(response.createdAt().toLocalDate()).isEqualTo(dining.getDate()); + verify(reportRepository).saveAndFlush(any(DiningReport.class)); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://outside.example/upload/COOP/soldout.jpg", + "https://static.koreatech.in.evil.example/upload/COOP/soldout.jpg", + "https://user:password@static.koreatech.in/upload/COOP/soldout.jpg", + "https://static.koreatech.in/upload/COOP/soldout.jpg?version=1", + "https://static.koreatech.in/upload/COOP/soldout.jpg#photo", + "https://static.koreatech.in/upload/SHOPS/soldout.jpg" + }) + void 허용되지_않은_URL은_S3_확인과_식단_잠금_전에_거부한다(String imageUrl) { + assertThatThrownBy(() -> service.create(42, 1, UUID.randomUUID(), new DiningReportCreateRequest(imageUrl))) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_REPORT_IMAGE.getMessage()); + verifyNoInteractions(amazonS3, diningRepository); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java index c79523298a..aa07201cf6 100644 --- a/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java +++ b/src/test/java/in/koreatech/koin/unit/global/auth/WebAuthLoggingTest.java @@ -64,6 +64,7 @@ class WebAuthLoggingTest { request.addHeader("AUTHORIZATION", "Bearer access-secret"); request.addHeader("Cookie", "koin-web-refresh=refresh-secret"); request.addHeader("X-CSRF-Token", "csrf-secret"); + request.addHeader("X-Koin-Service-Token", "bot-service-secret"); request.setContent("{\"login_pw\":\"password-secret\"}".getBytes(UTF_8)); new GlobalExceptionHandler().handleCustomException(new ContentCachingRequestWrapper(request), @@ -72,7 +73,7 @@ class WebAuthLoggingTest { String messages = appender.list.stream().map(ILoggingEvent::getFormattedMessage) .collect(joining("\n")); assertThat(messages).contains("[REDACTED]") - .doesNotContain("access-secret", "refresh-secret", "csrf-secret", "password-secret"); + .doesNotContain("access-secret", "refresh-secret", "csrf-secret", "password-secret", "bot-service-secret"); } finally { logger.detachAppender(appender); logger.setLevel(previousLevel);