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 5584363abc..0000000000 --- a/docs/web-cookie-auth.md +++ /dev/null @@ -1,173 +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` | -| `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, 현재 웹 세션 폐기 및 쿠키 삭제 | - -웹 인증 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 쿠키가 없으면 `/csrf`를 호출해 복구한다. 단순 새로고침마다 이 API를 호출할 필요는 없다. -로그인 시에는 기존 세션이 없으므로 -허용 출처 검사와 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`의 성공을 확인한 뒤 화면의 로그인 상태를 비운다. - -로그인·재발급·로그아웃·CSRF 조회는 만료된 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`를 사용한다. - -## 쿠키와 만료 설정 - -| 항목 | 운영(`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 전용 CSRF 조회·로그아웃에는 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/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/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/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/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/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/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/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/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/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/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/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/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/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); 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(); + } +} 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/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); 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);