From 1456d6b914f56d353818b1e6cd6379eb714e4923 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 24 Oct 2026 13:29:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=99=80=20=EB=AC=B4=EC=83=81=ED=83=9C=20=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...64\352\264\200\355\225\234\353\213\244.md" | 113 +++++ adr/README.md | 1 + .../domain/AlcoholCandidateRepository.java | 8 + .../alcohols/domain/ScenarioSelection.java | 4 + .../dto/dsl/AlcoholCandidateCriteria.java | 41 +- .../request/AdminAlcoholCandidateRequest.java | 25 +- .../dto/request/ScenarioEvaluateRequest.java | 31 ++ .../response/ScenarioEvaluationResponse.java | 47 ++ .../QueryDslAlcoholCandidateRepository.java | 80 +++- ...ultAdminAlcoholAttributeSearchService.java | 8 +- .../service/DefaultScenarioService.java | 173 ++++++++ .../service/ScenarioEvaluationService.java | 292 +++++++++++++ .../alcohols/service/ScenarioOptionCodec.java | 109 +++++ .../service/ScenarioOptionService.java | 98 +++++ .../service/ScenarioPreviewService.java | 237 ++-------- .../alcohols/service/ScenarioService.java | 10 + ...faultAdminAlcoholAttributeServiceTest.java | 17 + .../service/ScenarioDtoContractTest.java | 2 +- .../service/ScenarioOptionCodecTest.java | 106 +++++ .../controller/ScenarioController.java | 39 ++ .../controller/docs/ScenarioApiDocs.java | 67 +++ .../java/app/global/config/OpenApiConfig.java | 1 + .../ScenarioControllerIntegrationTest.java | 413 ++++++++++++++++++ ...cenarioOpenApiContractIntegrationTest.java | 49 +++ 24 files changed, 1722 insertions(+), 249 deletions(-) create mode 100644 "adr/2026.10.24 \354\202\254\354\232\251\354\236\220 \354\213\234\353\202\230\353\246\254\354\230\244\353\212\224 \353\254\264\354\203\201\355\203\234\353\241\234 \355\217\211\352\260\200\355\225\230\352\263\240 \353\213\265\353\263\200\354\235\200 \353\270\214\353\235\274\354\232\260\354\240\200\354\227\220 \353\263\264\352\264\200\355\225\234\353\213\244.md" create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/ScenarioEvaluateRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/ScenarioEvaluationResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioService.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java create mode 100644 bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/ScenarioController.java create mode 100644 bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/ScenarioApiDocs.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/ScenarioControllerIntegrationTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/global/integration/ScenarioOpenApiContractIntegrationTest.java diff --git "a/adr/2026.10.24 \354\202\254\354\232\251\354\236\220 \354\213\234\353\202\230\353\246\254\354\230\244\353\212\224 \353\254\264\354\203\201\355\203\234\353\241\234 \355\217\211\352\260\200\355\225\230\352\263\240 \353\213\265\353\263\200\354\235\200 \353\270\214\353\235\274\354\232\260\354\240\200\354\227\220 \353\263\264\352\264\200\355\225\234\353\213\244.md" "b/adr/2026.10.24 \354\202\254\354\232\251\354\236\220 \354\213\234\353\202\230\353\246\254\354\230\244\353\212\224 \353\254\264\354\203\201\355\203\234\353\241\234 \355\217\211\352\260\200\355\225\230\352\263\240 \353\213\265\353\263\200\354\235\200 \353\270\214\353\235\274\354\232\260\354\240\200\354\227\220 \353\263\264\352\264\200\355\225\234\353\213\244.md" new file mode 100644 index 000000000..490991047 --- /dev/null +++ "b/adr/2026.10.24 \354\202\254\354\232\251\354\236\220 \354\213\234\353\202\230\353\246\254\354\230\244\353\212\224 \353\254\264\354\203\201\355\203\234\353\241\234 \355\217\211\352\260\200\355\225\230\352\263\240 \353\213\265\353\263\200\354\235\200 \353\270\214\353\235\274\354\232\260\354\240\200\354\227\220 \353\263\264\352\264\200\355\225\234\353\213\244.md" @@ -0,0 +1,113 @@ +# 사용자 시나리오는 무상태로 평가하고 답변은 브라우저에 보관한다 + +- Decision Date(결정일): 2026.10.24 +- Scope(대상): Product 시나리오 조회·평가 API, 어드민 시나리오 정의, 캠페인 콘텐츠 연동, 브라우저 답변 보관 +- Related PRs(관련 PR): [어드민 시나리오 정의 관리 API #810](https://github.com/bottle-note/bottle-note-api-server/pull/810), [시나리오와 후보 검색 흐름 정리 #811](https://github.com/bottle-note/bottle-note-api-server/pull/811) +- Related ADRs(관련 ADR): [캠페인 콘텐츠 참여는 전용 이벤트 테이블에 기록한다](<2026.09.15 캠페인 콘텐츠 참여는 전용 이벤트 테이블에 기록하고 방문자 ID는 필터가 요청 전에 확정한다.md>), [Product 목록은 HMAC keyset cursor를 사용한다](<2026.08.15 Product 목록은 HMAC keyset cursor, Admin 목록은 page size로 분리한다.md>) + +## Context(배경) + +어드민은 속성 그룹 안에 여러 시나리오와 버전을 저장하고, 질문·선택지·명시적 이동 규칙을 정의한다. 기존 미리보기는 전달받은 답변으로 유효 경로와 다음 질문, 적용 조건, 후보 및 집계를 계산한다. 이 기능은 관리자용이며 사용자 실행을 저장하는 Product API는 아직 없다. + +바텐더 노트에서 어드민의 추천 시나리오를 샘플로 실행하며 질문 순서, 분기, 이전 답변 수정, 가격·도수 선택지, 중간 종료와 재개를 검토했다. 이때 필요한 것은 특정 진입점에 고정된 추천 기능이 아니라 시나리오 ID를 받아 N개의 시나리오를 같은 방식으로 평가하는 기능이다. 캠페인 콘텐츠도 같은 시나리오를 연결해 사용해야 한다. + +사용자는 브라우저에 보관한 답변으로 다시 추천받는다. 어제 같은 답변으로 50병이 나왔더라도 관리자가 주류·속성 연결 등을 변경해 오늘 45병이 된다면 45병을 보여주는 것이 요구사항이다. 이전 결과 집합이나 순서를 그대로 보존할 필요는 없다. 상세에서 돌아왔을 때도 답변과 화면 위치를 기억하면 되며, 서버가 추천 실행을 저장할 이유는 없다. + +캠페인에는 참여했다는 사실을 기록한다. 기존 캠페인 이벤트의 `RESULT`는 결과를 노출했다는 사실이지 추천된 주류 목록을 보관한다는 의미가 아니다. 답변 이력이나 결과 분석은 참여 집계와 다른 책임이며 이번 결정의 저장 범위에 포함하지 않는다. + +## Decision(결정) + +### Stateless Evaluation(무상태 평가) + +Product 시나리오는 조회와 평가 두 API로 제공한다. + +| API | 책임 | +|---|---| +| `GET /api/v1/scenarios/{scenarioId}` | 사용자에게 제공 가능한 시나리오의 게시 정의 정보, 버전, 첫 질문과 선택지를 반환한다. | +| `POST /api/v1/scenarios/{scenarioId}/evaluate` | 요청에 담긴 전체 답변과 조회 의도를 검증하고 질문·선택지·최신 추천 후보를 반환한다. 도메인 데이터를 변경하지 않는다. | + +- 호출자는 시나리오 ID를 전달한다. 서버가 바텐더 진입점 하나나 특정 속성 그룹·슬롯을 시나리오 선택의 고정 기준으로 삼지 않는다. 그룹 소속과 활성 여부 등 제공 가능 조건은 서버가 확인한다. +- 초안은 Product에 노출하지 않는다. 어드민이 게시하고 활성화한 정의를 사용한다. 로컬 샘플의 질문 생략 같은 교정은 Product가 별도로 하드코딩하지 않고 게시 정의에 반영한다. +- 평가 요청은 시나리오 버전, 순서 있는 전체 답변, 선택적으로 과거 질문 ID, `QUESTION` 또는 `RESULT` 조회 모드와 목록 페이징 정보를 담는다. 별도 `runId`, 실행 생성·저장·종료·재개 API는 만들지 않는다. +- 조회 응답은 정의의 버전을 명시한다. 평가 요청의 버전이 현재 제공 버전과 다르면 답변을 새 정의로 조용히 해석하지 않는다. 정의 재조회와 답변 재검증이 필요한 상태를 명시한다. 버전은 답변 해석의 호환성을 확인하기 위한 값이며 후보 데이터를 고정하는 값이 아니다. +- 응답은 평가에 사용한 버전, 유효 답변과 표시 문구, 현재 질문, 선택지별 문구·후보 수·선택 가능 여부, 이전 답변을 조회할 정보, 진행 상태와 후보를 제공한다. 프런트가 그래프 전체나 속성 저장 구조를 알아야 하는 계약으로 만들지 않는다. +- 후보·속성값·연결·맛 태그 등 검색 데이터는 평가 시점의 현재 상태를 읽는다. 브라우저 복귀, 재개, 결과 추가 조회도 다시 평가하므로 후보 수와 목록·순서가 이전 응답과 달라질 수 있다. 이전 실행 시점의 후보 스냅샷이나 고정 ID 집합은 저장하지 않는다. +- 결과 목록은 기존 Product 페이지네이션 원칙을 따른다. 페이징이 필요해도 같은 평가 API에 요청 정보를 전달하며 별도 실행별 추천 API를 추가하지 않는다. 페이지 사이 관리 데이터 변경에 따른 목록 변화는 결과 고정 기능으로 숨기지 않는다. + +### User Actions(사용자 행동) + +| 행동 | 평가 방식 | +|---|---| +| 다음 질문 | 현재 답변을 추가한 전체 답변을 `QUESTION` 모드로 평가한다. | +| 이전 질문 보기 | 답변은 보관한 채 `viewQuestionId`로 과거 질문을 지정한다. 후보와 선택지 집계에는 그 질문 직전까지의 답변만 적용한다. 조회만으로 답변을 삭제하지 않는다. | +| 답변 수정 | 수정한 질문까지의 답변만 제출한다. 서버는 시작 질문부터 순서와 분기를 검증하고 이후 질문을 다시 계산한다. 브라우저는 이전의 후속 답변을 폐기한다. | +| 바로 추천받기 | 화면에서 적용된 답변만 `RESULT` 모드로 제출한다. 현재 미응답 질문과 뒤 질문의 답변을 임의로 생성하거나 `ANY`로 채우지 않는다. 과거 질문을 보고 있다면 그 질문 직전의 답변까지만 적용한다. | +| 질문 더 보기 | 보관한 답변을 `QUESTION` 모드로 다시 평가한다. 유효 경로의 다음 미응답 질문부터 이어간다. | +| 상세에서 복귀 | 보관한 시나리오 ID·버전·답변으로 다시 평가하고, 브라우저가 결과 또는 질문 화면과 가능한 스크롤 위치를 복원한다. 후보는 현재 데이터로 갱신한다. | +| 처음부터 다시 하기 | 브라우저의 진행 상태를 비우고 같은 시나리오의 첫 질문을 조회한다. 서버 실행 삭제는 필요하지 않다. | + +이동 규칙, 입력 검증, 검색 조건 합성, 선택지 집계는 서버가 소유한다. `QUESTION` 모드에서 정의의 `END`에 도달하면 결과 화면에 필요한 응답을 반환한다. `RESULT` 요청에 따른 사용자 중간 종료와 정의의 `END`는 응답에서 구분하되, 이 구분을 서버의 실행 상태로 저장하지 않는다. + +### Answer Identity(답변 식별과 숫자 구간) + +- 프런트는 서버가 발급한 `optionId`를 선택한다. `ANY`도 명시적인 선택지로 취급한다. 가격·도수 구간 계산, 경계 포함 여부와 검색 조건을 프런트가 만들지 않는다. +- 가격·도수 선택지는 현재 후보 값의 분포를 바탕으로 계산하고 같은 경계와 빈 구간을 정리한다. 서로 인접한 구간은 경계가 겹치거나 소수 값이 빠지지 않아야 한다. 후보가 단일 값으로 모이면 의미 없는 여러 구간을 만들지 않는다. +- 동적 구간의 `optionId`는 서버에 사용자 실행을 저장하지 않아도 원래 조건을 재현하고 위변조를 검증할 수 있는 식별자로 발급한다. 시나리오·정의 버전·질문·선행 답변 문맥과 실제 비교 조건을 결합한다. 저장된 답변을 새 후보의 분위수 순번으로 다시 해석하지 않는다. +- 예를 들어 어제 선택한 구간이 `13,000원 초과~15,000원 이하`였다면 오늘도 그 조건을 적용하고 일치하는 주류만 현재 데이터에서 다시 구한다. 후보 수 갱신과 선택했던 조건의 의미 변경은 구분한다. +- 현재 Admin `ScenarioAnswer.NUMBER_RANGE`는 양끝 포함인 `min/max`만 지원하고 미지원 필드를 거절한다. Product의 동적 선택지·배타 경계는 별도 계약으로 정의하고 기존 Admin 요청을 그대로 공개하지 않는다. 가능한 경로 평가와 검색은 기존 구현을 재사용하되 사용자 계약의 차이는 명시적으로 처리한다. +- 존재하지 않는 선택지, 다른 시나리오의 답변, 중복 질문, 유효 경로 밖의 답변과 잘못된 순서는 거절한다. 저장된 정의·답변이 현재 제공 조건과 호환되지 않는 상황은 복구 가능한 상태로 알려 재조회·재선택하게 한다. 조용히 답변을 제거한 추천을 정상 결과처럼 보여주지 않는다. + +### Browser Storage(브라우저 보관) + +- 시나리오 ID·버전, 확정 답변, 보고 있던 질문 또는 결과 모드와 화면 복원 정보는 브라우저 Local Storage에 보관한다. 서버에는 사용자별 질문·답변 이력, 실행 상태, 추천 결과 테이블을 추가하지 않는다. +- 보관 키는 사용자 문맥과 진입 문맥을 구분한다. 캠페인에서 실행했다면 캠페인 코드와 시나리오 ID를 포함해 다른 캠페인·시나리오의 답변을 섞지 않는다. 로그인 계정이 바뀌었을 때 다른 사용자의 보관 상태를 복원하지 않는다. +- 서버는 Local Storage 내용을 신뢰하지 않고 모든 평가 요청을 검증한다. 보관 데이터가 없거나 손상됐거나 질문·선택지가 바뀌면 첫 조회 또는 재선택으로 복구한다. 구체적인 보관 기간과 삭제 UX는 프런트 계약에서 정한다. +- 브라우저 데이터 삭제·기기 변경에 따른 답변 유실을 서버 실행 저장으로 보완하지 않는다. 기기 간 동기화와 서버 답변 이력은 별도 요구로 다룬다. + +### Campaign Participation(캠페인 참여 기록) + +- 캠페인 콘텐츠는 연결할 시나리오 ID를 참조할 수 있고 여러 캠페인 또는 진입점이 같은 시나리오를 사용할 수 있다. 평가 API는 캠페인 콘텐츠 ID를 필수 인자로 받지 않는다. +- 참여 기록은 기존 `POST /api/v1/campaign-contents/{code}/events`와 `VIEW·START·FINISH·RESULT` 이벤트를 사용한다. 평가 호출 자체가 참여 이벤트를 자동 저장하지 않는다. 이전 질문 조회·재평가·페이징이 새 참여로 중복 기록되지 않도록 프런트가 실제 사용자 행동 시점에 별도로 이벤트를 보낸다. +- 시나리오에서 결과 보기로 넘어가는 사용자 중간 종료도 기존 `FINISH`의 의미와 연결하고, 로그인한 사용자에게 결과를 실제로 노출했을 때 `RESULT`를 보낸다. 질문 더 보기와 재평가는 그 자체로 새로운 참여 시작이 아니다. +- `RESULT`의 로그인 요구, 회원·방문자 식별, 통계 집계와 제외 기준은 기존 캠페인 ADR을 따른다. 이 결정으로 기존 캠페인의 인증 정책을 바꾸지 않는다. +- 참여 이벤트에는 기존 식별·시각·유형을 기록하며 답변, 추천 주류 ID 목록·순서·개수나 개별 결과 내용을 추가하지 않는다. `RESULT`는 결과 노출 사실을 뜻한다. 평가 요청 본문이나 응답을 별도 이력으로 쌓아 이 경계를 우회하지 않는다. + +### Cache and Tokens(캐시와 식별자) + +- 첫 구현은 Redis나 JVM 캐시에 시나리오, 활성 상태, 선택지와 후보를 저장하지 않는다. 매 요청의 읽기 전용 트랜잭션에서 현재 활성 게시본과 검색 데이터를 조회한다. 관리자가 변경한 데이터는 커밋 후 시작하는 요청에 반영하며 캐시 무효화 이벤트 누락에 의존하지 않는다. +- 캐시를 도입하려면 시나리오 활성화·비활성화, 속성·연결 수정, 주류 수정·삭제와 맛 태그 변경을 포함하는 무효화 계약을 별도로 검증한다. 성능 측정 없이 캐시부터 추가하지 않는다. +- `optionId`는 별도 용도로 구분한 HMAC SHA-256 서명을 사용하고 현재·이전 서명 키는 기존 커서 키 설정으로 관리한다. 선택지에 시간 만료를 넣지 않으며 활성 버전과 선행 답변의 정규화된 조건을 검증한다. 키를 폐기하면 그 키로 발급한 답변은 400으로 거절하고 다시 선택하게 한다. 커서와 선택지의 서명 용도는 구분한다. +- 숫자 선택지는 DB에서 구한 25·50·75 분위 경계를 질문의 `step`에 맞춰 내림한 뒤 최대 네 구간으로 나눈다. 첫 구간은 양끝 포함이고 이후 구간은 하한 초과·상한 이하다. 중복 경계와 후보가 없는 구간은 제거한다. 여러 값을 가진 주류는 여러 구간에 속할 수 있지만 한 구간의 후보 수에는 한 번만 집계한다. +- 선택지별 개수는 같은 검색 조건을 사용한 DB 조건 집계 한 번으로 계산한다. 분위 경계도 DB에서 조회하며 전체 후보나 속성값을 애플리케이션 메모리로 가져오지 않는다. +- 결과는 `alcoholId` 오름차순으로 조회한다. HMAC keyset 커서는 시나리오·버전·적용 답변·페이지 크기에 결합하며 1시간 후 만료한다. 커서 만료 시 같은 답변으로 첫 페이지부터 다시 조회한다. + +### Rejected Alternatives(선택하지 않은 대안) + +- 서버 실행 CRUD와 답변·종료·재개·결과별 API: 클라이언트가 전체 답변을 보관하고 같은 평가를 다시 요청하면 충분하다. 현재 요구에 없는 실행 저장과 상태 전이·동시성·정리 작업이 늘어난다. +- 추천 대상 ID와 순서의 스냅샷 보관: 관리자가 바꾼 최신 후보를 보여주려는 요구와 맞지 않는다. +- 바텐더 전용 진입점으로 시나리오 고정 선택: N개의 시나리오와 캠페인 재사용을 지원하지 못한다. +- 그래프와 숫자 필터를 프런트가 평가: 플랫폼마다 이동·검색·집계가 달라지고 관리자 정의와 사용자 결과가 어긋난다. +- 동적 선택지를 매번 현재 분포의 동일 순번으로 재해석: 같은 답변을 복원했는데 사용자가 선택한 가격·도수 조건 자체가 달라진다. +- 평가 요청마다 캠페인 참여를 자동 저장: 이전 질문 보기·재개·페이징 같은 조회가 참여 집계를 늘린다. + +## Status(상태) + +Accepted(승인됨) + +사용자와 합의한 설계 결정이다. ADR 승인과 배포·운영 검증은 구분한다. + +### Implementation Record(적용 기록) + +Product의 조회·평가 API와 서명한 선택지, 현재 데이터 재조회, 결과 keyset 페이징을 구현한다. 경로·답변 검증과 후보 조건 합성은 관리자 미리보기와 공유하고 관리자 JSON 계약은 유지한다. 별도 실행 저장소, 캐시와 마이그레이션은 추가하지 않는다. 구체적인 요청·응답 계약은 실행 중 OpenAPI로 제공한다. + +브라우저 Local Storage 연동과 캠페인 화면의 시나리오 연결은 프런트 작업 범위다. 이번 서버 API는 기존 참여 이벤트를 변경하거나 자동 저장하지 않는다. 로컬 UI 샘플은 서버 계약이나 운영 검증을 대체하지 않는다. + +## Consequences(결과와 영향) + +- 같은 조회·평가 API를 N개의 시나리오와 여러 캠페인에서 재사용한다. 서버 실행 저장·만료·복원·이력 관리가 필요 없고 참여 집계는 기존 캠페인 도메인에 남는다. +- 상세 복귀나 다음 날 재평가 시 입력 조건은 보존하지만 추천 대상과 수·순서는 현재 데이터에 따라 달라진다. 화면은 이전 후보 수나 고정 결과를 보장하지 않아야 한다. +- 전체 답변을 매 요청마다 검증하고 집계를 다시 계산한다. 서버에 실행 상태가 없다는 이유로 경로 검증을 생략하거나 브라우저가 보낸 필터를 그대로 적용해서는 안 된다. +- 동적 선택지는 원래 조건을 재현하고 검증할 식별자와 정의 호환성 처리가 필요하다. 분위수 경계, 배타 비교와 후보 정렬은 서버 계약으로 확정하고 테스트해야 한다. +- 원본 Admin 미리보기의 요청·응답을 그대로 Product에 공개할 수 없다. 관리자 감사 정보와 편집 구조를 제외하고 사용자 화면에 필요한 형태로 변환해야 한다. +- 브라우저 저장소를 지우거나 기기를 바꾸면 진행 상태를 복원하지 못한다. 서버 저장 이력, 기기 간 재개, 과거 추천 재현과 개인별 답변 분석은 별도의 결정 없이는 제공하지 않는다. +- 캠페인 참여 이벤트는 참여 사실을 보여주며 사용자가 어떤 답변이나 추천 결과를 받았는지 설명하지 않는다. 그 분석이 필요해지면 기존 참여 이벤트에 결과를 덧붙이지 않고 목적과 저장 범위를 별도로 결정한다. diff --git a/adr/README.md b/adr/README.md index 0930b0274..4d221b829 100644 --- a/adr/README.md +++ b/adr/README.md @@ -52,6 +52,7 @@ | 2026.09.07 | [통계 시계열은 표준 포맷 하나로 내리고 열린 버킷은 읽기 시점에 롤다운한다](<2026.09.07 통계 시계열은 표준 포맷 하나로 내리고 열린 버킷은 읽기 시점에 롤다운한다.md>) | | 2026.10.12 | [위스키 부가 정보는 속성 원장 하나로 기록하고 맛 그룹은 태그 사전으로 둔다](<2026.10.12 위스키 부가 정보는 속성 원장 하나로 기록하고 맛 그룹은 태그 사전으로 둔다.md>) | | 2026.10.16 | [주류 속성은 값과 조건을 가진 공통 사전과 수동 매핑으로 관리한다](<2026.10.16 주류 속성은 값과 조건을 가진 공통 사전과 수동 매핑으로 관리한다.md>) | +| 2026.10.24 | [사용자 시나리오는 무상태로 평가하고 답변은 브라우저에 보관한다](<2026.10.24 사용자 시나리오는 무상태로 평가하고 답변은 브라우저에 보관한다.md>) | ### 인증·보안 diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholCandidateRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholCandidateRepository.java index 6352b1fbf..73082937c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholCandidateRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholCandidateRepository.java @@ -3,6 +3,7 @@ import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria; import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateGroup; import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateTag; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; @@ -15,6 +16,13 @@ public interface AlcoholCandidateRepository { List findPage(AlcoholCandidateCriteria query); + List findQuartiles(AlcoholCandidateCriteria query, BigDecimal min, BigDecimal max); + + List countAlternatives( + AlcoholCandidateCriteria base, List alternatives); + + List findAfter(AlcoholCandidateCriteria query, Long afterId, int limit); + List findMappings(Long groupId, List alcoholIds); List findTags(List alcoholIds); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java new file mode 100644 index 000000000..81eb54350 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java @@ -0,0 +1,4 @@ +package app.bottlenote.alcohols.domain; + +/** 서명한 선택 조건은 후보 분포가 달라져도 같은 의미로 평가한다. */ +public record ScenarioSelection(ScenarioAnswer answer, boolean minExclusive, String label) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/AlcoholCandidateCriteria.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/AlcoholCandidateCriteria.java index 5d397bc37..33ee4b7ed 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/AlcoholCandidateCriteria.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/AlcoholCandidateCriteria.java @@ -1,18 +1,20 @@ package app.bottlenote.alcohols.dto.dsl; import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; -import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.Condition; -import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.NextQuestion; +import app.bottlenote.alcohols.constant.AlcoholQuestionSource; +import app.bottlenote.alcohols.constant.AttributeSearchOperator; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; public record AlcoholCandidateCriteria( Long groupId, String keyword, - List include, - List exclude, + List include, + List exclude, List filters, - NextQuestion nextQuestion, + NextQuestionCriteria nextQuestion, int page, int size) { public record BaseFilterCriteria( @@ -20,5 +22,32 @@ public record BaseFilterCriteria( BigDecimal abvFrom, BigDecimal abvTo, List tastingTagIds, - boolean requiresTasting) {} + boolean requiresTasting, + boolean abvFromExclusive) { + public BaseFilterCriteria( + List categories, + BigDecimal abvFrom, + BigDecimal abvTo, + List tastingTagIds, + boolean requiresTasting) { + this(categories, abvFrom, abvTo, tastingTagIds, requiresTasting, false); + } + } + + public record ConditionCriteria( + @NotNull @Positive Long attributeId, + @NotNull AttributeSearchOperator operator, + @Size(max = 255) String value) {} + + public record NextQuestionCriteria( + @NotNull AlcoholQuestionSource source, + @Positive Long attributeId, + @Size(max = 20) List<@NotNull @Valid RangeCriteria> ranges) { + public NextQuestionCriteria { + ranges = ranges == null ? List.of() : List.copyOf(ranges); + } + } + + public record RangeCriteria( + @NotBlank @Size(max = 64) String key, BigDecimal from, BigDecimal to) {} } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholCandidateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholCandidateRequest.java index c3419db88..4b64f318f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholCandidateRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholCandidateRequest.java @@ -1,8 +1,7 @@ package app.bottlenote.alcohols.dto.request; import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; -import app.bottlenote.alcohols.constant.AlcoholQuestionSource; -import app.bottlenote.alcohols.constant.AttributeSearchOperator; +import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.*; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.math.BigDecimal; @@ -11,13 +10,13 @@ /** 그룹 후보 필터. include는 AND, exclude는 하나라도 만족하면 술 전체 제외. */ public record AdminAlcoholCandidateRequest( @Size(max = 200) String keyword, - @Size(max = 20) List<@NotNull @Valid Condition> include, - @Size(max = 20) List<@NotNull @Valid Condition> exclude, + @Size(max = 20) List<@NotNull @Valid ConditionCriteria> include, + @Size(max = 20) List<@NotNull @Valid ConditionCriteria> exclude, @Size(max = 6) List<@NotNull AlcoholCategoryGroup> categoryGroups, @DecimalMin("0") @DecimalMax("100") BigDecimal abvFrom, @DecimalMin("0") @DecimalMax("100") BigDecimal abvTo, @Size(max = 20) List<@NotBlank @Size(max = 64) String> tastingGroupCodes, - @Valid NextQuestion nextQuestion, + @Valid NextQuestionCriteria nextQuestion, @Min(0) Integer page, @Min(1) @Max(100) Integer size) { @@ -30,20 +29,4 @@ public record AdminAlcoholCandidateRequest( keyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); page = page == null ? 0 : page; } - - public record Condition( - @NotNull @Positive Long attributeId, - @NotNull AttributeSearchOperator operator, - @Size(max = 255) String value) {} - - public record NextQuestion( - @NotNull AlcoholQuestionSource source, - @Positive Long attributeId, - @Size(max = 20) List<@NotNull @Valid Range> ranges) { - public NextQuestion { - ranges = ranges == null ? List.of() : List.copyOf(ranges); - } - } - - public record Range(@NotBlank @Size(max = 64) String key, BigDecimal from, BigDecimal to) {} } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/ScenarioEvaluateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/ScenarioEvaluateRequest.java new file mode 100644 index 000000000..a312558be --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/ScenarioEvaluateRequest.java @@ -0,0 +1,31 @@ +package app.bottlenote.alcohols.dto.request; + +import com.fasterxml.jackson.annotation.JsonCreator; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +public record ScenarioEvaluateRequest( + @NotNull @Positive Long versionId, + @NotNull @Size(max = 100) List<@NotNull @Valid ScenarioSubmittedAnswer> answers, + @NotNull Mode mode, + @Positive Long viewQuestionId, + @Size(max = 4096) String cursor, + @Min(1) @Max(100) Integer size) { + public ScenarioEvaluateRequest { + size = size == null ? 20 : size; + } + + public enum Mode { + QUESTION, + RESULT; + + @JsonCreator + public static Mode parse(String value) { + return value == null ? null : valueOf(value); + } + } + + public record ScenarioSubmittedAnswer( + @NotNull @Positive Long questionId, @NotBlank @Size(max = 2048) String optionId) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/ScenarioEvaluationResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/ScenarioEvaluationResponse.java new file mode 100644 index 000000000..fa0bc5995 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/ScenarioEvaluationResponse.java @@ -0,0 +1,47 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.constant.ScenarioTypes.AnswerKind; +import app.bottlenote.global.pagination.KeysetPageResponse; +import java.math.BigDecimal; +import java.util.List; + +public record ScenarioEvaluationResponse( + Long scenarioId, + String title, + Long versionId, + State state, + Long nextQuestionId, + List path, + List answers, + ScenarioQuestion question, + long candidateCount, + KeysetPageResponse> candidates) { + public enum State { + QUESTION, + USER_FINISHED, + SCENARIO_END + } + + public record ScenarioAnswerView( + Long questionId, String optionId, String label, boolean applied) {} + + public record ScenarioQuestion(Long questionId, String title, List options) {} + + public record ScenarioOption( + String optionId, + String label, + AnswerKind kind, + BigDecimal min, + BigDecimal max, + boolean minExclusive, + long candidateCount, + boolean selectable) {} + + public record ScenarioCandidate( + Long alcoholId, + String korName, + String engName, + String imageUrl, + String korCategory, + String abv) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/QueryDslAlcoholCandidateRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/QueryDslAlcoholCandidateRepository.java index b0219a99c..b72b06fef 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/QueryDslAlcoholCandidateRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/QueryDslAlcoholCandidateRepository.java @@ -11,16 +11,18 @@ import app.bottlenote.alcohols.constant.AttributeSearchOperator; import app.bottlenote.alcohols.domain.*; import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria; -import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.Condition; +import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.ConditionCriteria; import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateGroup; import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateTag; import app.bottlenote.common.annotation.JpaRepositoryImpl; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.*; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -73,6 +75,69 @@ public List findPage(AlcoholCandidateCriteria query) { .fetch(); } + @Override + public List findQuartiles( + AlcoholCandidateCriteria query, BigDecimal min, BigDecimal max) { + var next = query.nextQuestion(); + var number = + next.source() == AlcoholQuestionSource.ABV + ? abvNumber() + : Expressions.numberTemplate( + BigDecimal.class, + "cast({0} as BigDecimal(24,6))", + alcoholAttributeMapping.attributeValue); + var values = + factory.select(number).from(alcohol).where(predicate(query), number.between(min, max)); + if (next.source() == AlcoholQuestionSource.ATTRIBUTE) { + values + .join(alcoholAttributeMapping) + .on( + alcoholAttributeMapping.alcoholId.eq(alcohol.id), + alcoholAttributeMapping.attributeId.eq(next.attributeId())); + query.include().stream() + .filter( + c -> c.attributeId().equals(next.attributeId()) && c.operator().isNumericComparison()) + .forEach(c -> values.where(numericValue(alcoholAttributeMapping, c))); + } + Long count = values.clone().select(number.count()).fetchOne(); + if (count == null || count == 0) return List.of(); + List cuts = new ArrayList<>(); + for (int quartile = 1; quartile < 4; quartile++) { + cuts.add( + values + .clone() + .orderBy(number.asc()) + .offset((count * quartile - 1) / 4) + .limit(1) + .fetchOne()); + } + return List.copyOf(cuts); + } + + @Override + public List countAlternatives( + AlcoholCandidateCriteria base, List alternatives) { + if (alternatives.isEmpty()) return List.of(); + var counts = + alternatives.stream() + .map(q -> new CaseBuilder().when(predicate(q)).then(1L).otherwise(0L).sumLong()) + .toList(); + var row = + factory + .select(counts.toArray(Expression[]::new)) + .from(alcohol) + .where(predicate(base)) + .fetchOne(); + return counts.stream().map(c -> row == null || row.get(c) == null ? 0L : row.get(c)).toList(); + } + + @Override + public List findAfter(AlcoholCandidateCriteria query, Long afterId, int limit) { + var where = predicate(query); + if (afterId != null) where.and(alcohol.id.gt(afterId)); + return factory.selectFrom(alcohol).where(where).orderBy(alcohol.id.asc()).limit(limit).fetch(); + } + @Override public List findMappings(Long groupId, List ids) { if (ids.isEmpty()) return List.of(); @@ -202,7 +267,7 @@ private BooleanBuilder predicate(AlcoholCandidateCriteria query) { var ranges = r.include().stream() .filter(c -> c.operator().isNumericComparison()) - .collect(java.util.stream.Collectors.groupingBy(Condition::attributeId)); + .collect(java.util.stream.Collectors.groupingBy(ConditionCriteria::attributeId)); for (var conditions : ranges.values()) { var m = new QAlcoholAttributeMapping("candidateRangeMapping"); var range = @@ -219,7 +284,11 @@ private BooleanBuilder predicate(AlcoholCandidateCriteria query) { if (filter.abvFrom() != null || filter.abvTo() != null) { var number = abvNumber(); where.and(number.between(BigDecimal.ZERO, new BigDecimal("100"))); - if (filter.abvFrom() != null) where.and(number.goe(filter.abvFrom())); + if (filter.abvFrom() != null) + where.and( + filter.abvFromExclusive() + ? number.gt(filter.abvFrom()) + : number.goe(filter.abvFrom())); if (filter.abvTo() != null) where.and(number.loe(filter.abvTo())); } if (filter.requiresTasting()) { @@ -245,7 +314,7 @@ private NumberExpression abvNumber() { "^[0-9]+([.][0-9]+)?[[:space:]]*%?$"); } - private BooleanExpression condition(Condition c) { + private BooleanExpression condition(ConditionCriteria c) { var m = new QAlcoholAttributeMapping("candidateConditionMapping"); var base = JPAExpressions.selectOne() @@ -276,7 +345,8 @@ private BooleanExpression condition(Condition c) { return base.where(value).exists(); } - private BooleanExpression numericValue(QAlcoholAttributeMapping mapping, Condition condition) { + private BooleanExpression numericValue( + QAlcoholAttributeMapping mapping, ConditionCriteria condition) { var n = Expressions.numberTemplate( BigDecimal.class, "cast({0} as BigDecimal(24,6))", mapping.attributeValue); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeSearchService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeSearchService.java index fa3717592..cf0db749c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeSearchService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeSearchService.java @@ -7,9 +7,9 @@ import app.bottlenote.alcohols.constant.AlcoholQuestionSource; import app.bottlenote.alcohols.domain.*; import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria; +import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.*; import app.bottlenote.alcohols.dto.request.AdminAlcoholAttributeConditionRequest; import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest; -import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.*; import app.bottlenote.alcohols.dto.request.AdminAlcoholCompoundSearchRequest; import app.bottlenote.alcohols.dto.request.AdminAttributedAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholAttributeFacetsResponse; @@ -353,13 +353,13 @@ private AdminAlcoholCandidateRequest normalize( r.size()); } - private List normalizeConditions( - List conditions, Map definitions) { + private List normalizeConditions( + List conditions, Map definitions) { return conditions.stream() .map( c -> { var attribute = requireDefinition(c.attributeId(), definitions); - return new Condition( + return new ConditionCriteria( c.attributeId(), c.operator(), attribute.normalizeSearchValue(c.operator(), c.value())); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java new file mode 100644 index 000000000..082637eb3 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java @@ -0,0 +1,173 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.exception.ScenarioExceptionCode.*; + +import app.bottlenote.alcohols.constant.ScenarioTypes.Status; +import app.bottlenote.alcohols.domain.*; +import app.bottlenote.alcohols.dto.request.ScenarioEvaluateRequest; +import app.bottlenote.alcohols.dto.request.ScenarioEvaluateRequest.Mode; +import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse; +import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse.*; +import app.bottlenote.alcohols.exception.ScenarioException; +import app.bottlenote.alcohols.service.ScenarioEvaluationService.*; +import app.bottlenote.global.pagination.*; +import java.util.*; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DefaultScenarioService implements ScenarioService { + private final AlcoholScenarioRepository scenarios; + private final AlcoholScenarioVersionRepository versions; + private final ScenarioDefinitionStore definitions; + private final ScenarioEvaluationService evaluator; + private final AlcoholCandidateRepository candidates; + private final ScenarioOptionCodec options; + private final ScenarioOptionService optionService; + private final HmacCursorCodec cursors; + + @Override + @Transactional(readOnly = true) + public ScenarioEvaluationResponse get(Long scenarioId) { + var published = published(scenarioId); + return evaluate( + published, + new ScenarioEvaluateRequest( + published.version().getId(), List.of(), Mode.QUESTION, null, null, 20)); + } + + @Override + @Transactional(readOnly = true) + public ScenarioEvaluationResponse evaluate(Long scenarioId, ScenarioEvaluateRequest request) { + var published = published(scenarioId); + if (!published.version().getId().equals(request.versionId())) + throw new ScenarioException(SCENARIO_CONFLICT); + return evaluate(published, request); + } + + private Published published(Long id) { + var s = scenarios.findById(id).orElseThrow(() -> new ScenarioException(SCENARIO_NOT_FOUND)); + if (!s.isEnabled() || s.getActiveVersionId() == null) + throw new ScenarioException(SCENARIO_NOT_FOUND); + var v = + versions + .findById(s.getActiveVersionId()) + .orElseThrow(() -> new ScenarioException(SCENARIO_NOT_FOUND)); + if (v.getStatus() != Status.PUBLISHED || !v.getScenarioId().equals(s.getId())) + throw new ScenarioException(SCENARIO_NOT_FOUND); + return new Published(s, v, definitions.read(v)); + } + + private ScenarioEvaluationResponse evaluate(Published p, ScenarioEvaluateRequest r) { + Long id = p.scenario().getId(), version = p.version().getId(); + List selections = new ArrayList<>(); + for (var answer : r.answers()) { + selections.add( + options.decode( + answer.optionId(), options.context(id, version, selections), answer.questionId())); + } + var e = + evaluator.evaluate( + p.scenario().getGroupId(), + p.definition(), + new EvaluationInput( + selections.stream().map(ScenarioSelection::answer).toList(), + null, + r.viewQuestionId(), + 0, + r.size(), + selections.stream() + .filter(ScenarioSelection::minExclusive) + .map(s -> s.answer().questionId()) + .collect(Collectors.toSet()))); + int applied = + r.viewQuestionId() == null ? selections.size() : e.path().indexOf(r.viewQuestionId()); + State state = + r.mode() == Mode.RESULT + ? State.USER_FINISHED + : e.viewedQuestionId() == null ? State.SCENARIO_END : State.QUESTION; + ScenarioQuestion question = null; + long total = candidates.count(e.query()); + if (state == State.QUESTION) { + var q = + p.definition().questions().stream() + .filter(item -> item.id().equals(e.viewedQuestionId())) + .findFirst() + .orElseThrow(); + question = + new ScenarioQuestion( + q.id(), + q.title(), + optionService.options( + q, e, options.context(id, version, selections.subList(0, applied)))); + } + List answers = new ArrayList<>(); + for (int i = 0; i < selections.size(); i++) { + var submitted = r.answers().get(i); + answers.add( + new ScenarioAnswerView( + submitted.questionId(), + submitted.optionId(), + selections.get(i).label(), + i < applied)); + } + return new ScenarioEvaluationResponse( + id, + p.scenario().getTitle(), + version, + state, + e.nextQuestionId(), + e.path(), + List.copyOf(answers), + question, + total, + resultPage(p, r, selections.subList(0, applied), e, state)); + } + + private KeysetPageResponse> resultPage( + Published published, + ScenarioEvaluateRequest r, + List applied, + Evaluation e, + State state) { + if (state == State.QUESTION) { + if (r.cursor() != null) throw ScenarioException.invalid("결과 화면에서만 cursor를 사용합니다."); + return KeysetPageResponse.of(List.of(), new KeysetPagination(false, null)); + } + String context = + "scenario-results-v1:" + + options.context(published.scenario().getId(), published.version().getId(), applied) + + ":" + + r.size(); + Long after = + r.cursor() == null + ? null + : CursorKeys.requireLong(cursors.verify(r.cursor(), context), "id"); + var slice = + KeysetPagination.fromOverflow( + candidates.findAfter(e.query(), after, r.size() + 1), + r.size(), + alcohol -> cursors.encode(context, Map.of("id", alcohol.getId().toString()))); + return KeysetPageResponse.of( + slice.items().stream() + .map( + a -> + new ScenarioCandidate( + a.getId(), + a.getKorName(), + a.getEngName(), + a.getImageUrl(), + a.getKorCategory(), + a.getAbv())) + .toList(), + slice.pagination()); + } + + private record Published( + AlcoholAttributeScenario scenario, + AlcoholAttributeScenarioVersion version, + ScenarioDefinition definition) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java new file mode 100644 index 000000000..bee7ea8cf --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java @@ -0,0 +1,292 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.exception.ScenarioException.invalid; + +import app.bottlenote.alcohols.constant.*; +import app.bottlenote.alcohols.constant.ScenarioTypes.*; +import app.bottlenote.alcohols.domain.AlcoholAttribute; +import app.bottlenote.alcohols.domain.AlcoholCandidateRepository; +import app.bottlenote.alcohols.domain.ScenarioAnswer; +import app.bottlenote.alcohols.domain.ScenarioAnswer.ScenarioContribution; +import app.bottlenote.alcohols.domain.ScenarioDefinition; +import app.bottlenote.alcohols.domain.ScenarioDefinition.*; +import app.bottlenote.alcohols.domain.ScenarioSelection; +import app.bottlenote.alcohols.domain.TastingTagGroup; +import app.bottlenote.alcohols.domain.TastingTagGroupRepository; +import app.bottlenote.alcohols.domain.TastingTagGroupTag; +import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria; +import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.*; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ScenarioEvaluationService { + private final ScenarioDefinitionValidator validator; + private final AlcoholCandidateRepository candidateRepository; + private final TastingTagGroupRepository tastingGroupRepository; + + @Transactional(readOnly = true) + public Evaluation evaluate(Long groupId, ScenarioDefinition d, EvaluationInput r) { + var attributes = + candidateRepository.findAttributes(groupId).stream() + .collect(Collectors.toMap(AlcoholAttribute::getId, Function.identity())); + var groups = tastingGroupRepository.findAllWithTags(); + var codes = groups.stream().map(TastingTagGroup::getCode).collect(Collectors.toSet()); + validator.validate(d, groupId, attributes, codes, true); + var questions = + d.questions().stream() + .collect(Collectors.toMap(ScenarioQuestionItem::id, Function.identity())); + var rules = + d.transitions().stream() + .collect(Collectors.groupingBy(ScenarioTransitionItem::fromQuestionId)); + Set submitted = new HashSet<>(); + for (var a : r.answers()) { + if (!submitted.add(a.questionId()) || !questions.containsKey(a.questionId())) + throw invalid("질문 " + a.questionId() + ": 중복 또는 다른 버전의 질문 답변입니다."); + } + List input = r.answers(); + List discarded = List.of(); + if (r.changedQuestionId() != null) { + int index = -1; + for (int i = 0; i < input.size(); i++) + if (input.get(i).questionId().equals(r.changedQuestionId())) index = i; + if (index < 0) throw invalid("changedQuestionId의 답변이 필요합니다."); + discarded = + input.subList(index + 1, input.size()).stream().map(ScenarioAnswer::questionId).toList(); + input = input.subList(0, index + 1); + } + List path = new ArrayList<>(); + List accepted = new ArrayList<>(); + Long next = d.startQuestionId(); + for (var a : input) { + if (!Objects.equals(a.questionId(), next)) + throw invalid("질문 " + a.questionId() + ": 현재 유효 경로의 다음 질문 " + next + "에 대한 답변이 필요합니다."); + var q = questions.get(next); + validateAnswer(q, a); + path.add(next); + accepted.add(a); + var rule = + rules.get(next).stream() + .sorted(Comparator.comparing(ScenarioTransitionItem::priority)) + .filter(t -> matches(t, a)) + .findFirst() + .orElseThrow(() -> invalid("질문 " + a.questionId() + ": 일치하는 명시적 이동 규칙이 없습니다.")); + next = rule.targetKind() == Target.END ? null : rule.targetQuestionId(); + } + if (next != null) path.add(next); + int prefix = accepted.size(); + Long viewed = r.viewQuestionId() == null ? next : r.viewQuestionId(); + if (r.viewQuestionId() != null) { + prefix = path.indexOf(r.viewQuestionId()); + if (prefix < 0) throw invalid("viewQuestionId는 현재 유효 경로의 질문이어야 합니다."); + } + List contributions = new ArrayList<>(); + for (var a : accepted.subList(0, prefix)) + contributions.addAll( + contribute( + questions.get(a.questionId()), + a, + attributes, + codes, + r.exclusiveMinQuestionIds().contains(a.questionId()))); + NextQuestionCriteria facet = null; + if (viewed != null) { + var q = questions.get(viewed); + facet = new NextQuestionCriteria(q.sourceKind(), q.attributeId(), List.of()); + } + var query = + candidateQuery( + new AlcoholCandidateCriteria( + groupId, null, List.of(), List.of(), List.of(), facet, r.page(), r.size()), + contributions, + attributes, + groups); + return new Evaluation( + List.copyOf(path), + next, + next == null, + List.copyOf(accepted), + discarded, + List.copyOf(contributions), + viewed, + query, + attributes, + groups); + } + + AlcoholCandidateCriteria withSelection( + Evaluation e, ScenarioQuestionItem question, ScenarioSelection selection) { + List contributions = new ArrayList<>(e.contributions()); + var codes = + e.tastingGroups().stream().map(TastingTagGroup::getCode).collect(Collectors.toSet()); + contributions.addAll( + contribute(question, selection.answer(), e.attributes(), codes, selection.minExclusive())); + return candidateQuery(e.query(), contributions, e.attributes(), e.tastingGroups()); + } + + private AlcoholCandidateCriteria candidateQuery( + AlcoholCandidateCriteria base, + List contributions, + Map attributes, + List groups) { + List include = new ArrayList<>(); + List filters = new ArrayList<>(); + for (var c : contributions) { + var operator = AttributeSearchOperator.valueOf(c.operator()); + switch (c.source()) { + case ATTRIBUTE -> { + var attribute = attributes.get(c.attributeId()); + if (operator == AttributeSearchOperator.EQ && attribute.getDatatype().isNumeric()) { + include.add( + new ConditionCriteria(c.attributeId(), AttributeSearchOperator.GTE, c.value())); + include.add( + new ConditionCriteria(c.attributeId(), AttributeSearchOperator.LTE, c.value())); + } else include.add(new ConditionCriteria(c.attributeId(), operator, c.value())); + } + case CATEGORY -> + filters.add( + new AlcoholCandidateCriteria.BaseFilterCriteria( + List.of(AlcoholCategoryGroup.valueOf(c.value())), + null, + null, + List.of(), + false)); + case TASTING_GROUP -> + filters.add( + new AlcoholCandidateCriteria.BaseFilterCriteria( + List.of(), + null, + null, + groups.stream() + .filter(g -> g.getCode().equals(c.value())) + .flatMap(g -> g.getTags().stream()) + .filter(TastingTagGroupTag::isCore) + .map(TastingTagGroupTag::getTastingTagId) + .distinct() + .toList(), + true)); + case ABV -> { + var value = new BigDecimal(c.value()); + filters.add( + new AlcoholCandidateCriteria.BaseFilterCriteria( + List.of(), + operator == AttributeSearchOperator.LTE ? null : value, + operator == AttributeSearchOperator.GTE || operator == AttributeSearchOperator.GT + ? null + : value, + List.of(), + false, + operator == AttributeSearchOperator.GT)); + } + } + } + return new AlcoholCandidateCriteria( + base.groupId(), + null, + include, + List.of(), + filters, + base.nextQuestion(), + base.page(), + base.size()); + } + + private void validateAnswer(ScenarioQuestionItem q, ScenarioAnswer a) { + String at = "질문 " + q.id() + ": "; + if (a.kind() == null) throw invalid(at + "답변 kind가 필요합니다."); + var c = q.inputConfig(); + switch (a.kind()) { + case ANY -> { + if (!c.allowAny() || a.choiceId() != null || a.min() != null || a.max() != null) + throw invalid(at + "허용된 ANY는 다른 답변 값을 포함하지 않습니다."); + } + case CHOICE -> { + if (q.inputKind() != Input.SINGLE_CHOICE + || a.min() != null + || a.max() != null + || c.choices().stream().noneMatch(x -> x.id().equals(a.choiceId()))) + throw invalid(at + "존재하는 choiceId 하나가 필요합니다."); + } + case NUMBER_RANGE -> { + if (q.inputKind() != Input.NUMBER_RANGE + || a.choiceId() != null + || a.min() == null + || a.max() == null + || a.min().precision() > 21 + || a.max().precision() > 21 + || a.min().scale() > 6 + || a.max().scale() > 6 + || a.min().compareTo(a.max()) > 0 + || a.min().compareTo(c.min()) < 0 + || a.max().compareTo(c.max()) > 0 + || a.min().subtract(c.min()).remainder(c.step()).signum() != 0 + || a.max().subtract(c.min()).remainder(c.step()).signum() != 0) + throw invalid(at + "설정한 min/max/step에 맞는 숫자 범위가 필요합니다."); + } + } + } + + private boolean matches(ScenarioTransitionItem t, ScenarioAnswer a) { + return t.predicate().matches(a.kind(), a.choiceId(), a.min(), a.max()); + } + + private List contribute( + ScenarioQuestionItem q, + ScenarioAnswer a, + Map attrs, + Set codes, + boolean minExclusive) { + if (a.kind() == AnswerKind.ANY) return List.of(); + if (a.kind() == AnswerKind.CHOICE) { + var choice = + q.inputConfig().choices().stream() + .filter(c -> c.id().equals(a.choiceId())) + .findFirst() + .orElseThrow(); + var value = + validator.choiceValue( + q, + q.attributeId() == null ? null : attrs.get(q.attributeId()), + codes, + choice.value()); + return List.of( + new ScenarioContribution(q.id(), q.sourceKind(), q.attributeId(), "EQ", value)); + } else { + return List.of( + new ScenarioContribution( + q.id(), + q.sourceKind(), + q.attributeId(), + minExclusive ? "GT" : "GTE", + a.min().toPlainString()), + new ScenarioContribution( + q.id(), q.sourceKind(), q.attributeId(), "LTE", a.max().toPlainString())); + } + } + + public record EvaluationInput( + List answers, + Long changedQuestionId, + Long viewQuestionId, + int page, + int size, + Set exclusiveMinQuestionIds) {} + + public record Evaluation( + List path, + Long nextQuestionId, + boolean completed, + List answers, + List discardedQuestionIds, + List contributions, + Long viewedQuestionId, + AlcoholCandidateCriteria query, + Map attributes, + List tastingGroups) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java new file mode 100644 index 000000000..6d3a9ae50 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java @@ -0,0 +1,109 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.exception.ScenarioException.invalid; + +import app.bottlenote.alcohols.domain.ScenarioSelection; +import app.bottlenote.global.pagination.CursorProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ScenarioOptionCodec { + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private final CursorProperties properties; + private final ObjectMapper mapper; + + public String context(Long scenarioId, Long versionId, List prefix) { + // 표기 문구와 서명 키 교체가 선행 답변의 의미를 바꾸지 않는다. + var values = + prefix.stream() + .map( + s -> + List.of( + s.answer().questionId(), + s.answer().kind().name(), + s.answer().choiceId() == null ? "" : s.answer().choiceId(), + decimal(s.answer().min()), + decimal(s.answer().max()), + s.minExclusive())) + .toList(); + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(json(List.of("scenario-options-v1", scenarioId, versionId, values)))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + public String encode(String context, ScenarioSelection selection) { + String keyId = + ENCODER.encodeToString(properties.getCurrentKeyId().getBytes(StandardCharsets.UTF_8)); + String body = ENCODER.encodeToString(json(new Payload(1, context, selection))); + String input = keyId + "." + body; + return input + "." + ENCODER.encodeToString(sign(input, properties.currentSecretBytes())); + } + + public ScenarioSelection decode(String token, String context, Long questionId) { + if (token == null || token.length() > 2048) throw invalid("선택지를 다시 조회해 주세요."); + try { + String[] parts = token.split("\\.", -1); + if (parts.length != 3) throw invalid("선택지 형식이 올바르지 않습니다."); + String keyId = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8); + byte[] secret = + properties.secretFor(keyId).orElseThrow(() -> invalid("선택지 서명 키가 만료됐습니다. 다시 선택해 주세요.")); + byte[] expected = sign(parts[0] + "." + parts[1], secret); + if (!MessageDigest.isEqual(expected, Base64.getUrlDecoder().decode(parts[2]))) + throw invalid("변조된 선택지입니다."); + Payload p = mapper.readValue(Base64.getUrlDecoder().decode(parts[1]), Payload.class); + if (p.format() != 1 + || !context.equals(p.context()) + || p.selection() == null + || p.selection().answer() == null + || !questionId.equals(p.selection().answer().questionId())) + throw invalid("다른 시나리오·질문 또는 선행 답변의 선택지입니다."); + return p.selection(); + } catch (IllegalArgumentException | java.io.IOException e) { + throw invalid("선택지 형식이 올바르지 않습니다."); + } + } + + private String decimal(BigDecimal value) { + return value == null ? "" : value.stripTrailingZeros().toPlainString(); + } + + private byte[] json(Object value) { + try { + return mapper.writeValueAsBytes(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private byte[] sign(String input, byte[] secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + return mac.doFinal( + ("bottlenote-scenario-option-v1:" + input).getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + private record Payload(int format, String context, ScenarioSelection selection) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java new file mode 100644 index 000000000..610bfd945 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java @@ -0,0 +1,98 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.constant.ScenarioTypes.*; +import app.bottlenote.alcohols.domain.*; +import app.bottlenote.alcohols.domain.ScenarioDefinition.ScenarioQuestionItem; +import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse.ScenarioOption; +import app.bottlenote.alcohols.service.ScenarioEvaluationService.Evaluation; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ScenarioOptionService { + private final AlcoholCandidateRepository repository; + private final ScenarioOptionCodec codec; + private final ScenarioEvaluationService evaluator; + + @Transactional(readOnly = true) + public List options(ScenarioQuestionItem q, Evaluation e, String context) { + List selections = new ArrayList<>(); + if (q.inputKind() == Input.NUMBER_RANGE) { + numericOptions(q, e, selections); + } else { + for (var choice : q.inputConfig().choices()) + selections.add( + new ScenarioSelection( + new ScenarioAnswer(q.id(), AnswerKind.CHOICE, choice.id(), null, null), + false, + choice.label())); + } + if (q.inputConfig().allowAny()) + selections.add( + new ScenarioSelection( + new ScenarioAnswer(q.id(), AnswerKind.ANY, null, null, null), false, "상관없어요")); + var counts = + repository.countAlternatives( + e.query(), selections.stream().map(s -> evaluator.withSelection(e, q, s)).toList()); + List options = new ArrayList<>(); + for (int i = 0; i < selections.size(); i++) { + var s = selections.get(i); + var a = s.answer(); + long count = counts.get(i); + if (a.kind() == AnswerKind.NUMBER_RANGE && count == 0) continue; + options.add( + new ScenarioOption( + codec.encode(context, s), + s.label(), + a.kind(), + a.min(), + a.max(), + s.minExclusive(), + count, + count > 0)); + } + return List.copyOf(options); + } + + private void numericOptions( + ScenarioQuestionItem q, Evaluation e, List selections) { + var config = q.inputConfig(); + var quartiles = repository.findQuartiles(e.query(), config.min(), config.max()); + if (quartiles.isEmpty()) return; + var cuts = new TreeSet(); + for (var value : quartiles) { + BigDecimal cut = + value + .subtract(config.min()) + .divide(config.step(), 0, RoundingMode.FLOOR) + .multiply(config.step()) + .add(config.min()); + if (cut.compareTo(config.max()) < 0) cuts.add(cut); + } + cuts.add(config.max()); + BigDecimal lower = config.min(); + boolean exclusive = false; + for (var upper : cuts) { + String unit = config.unit() == null ? "" : config.unit(); + String label = + lower.toPlainString() + + unit + + (exclusive ? " 초과 ~ " : " ~ ") + + upper.toPlainString() + + unit + + " 이하"; + selections.add( + new ScenarioSelection( + new ScenarioAnswer(q.id(), AnswerKind.NUMBER_RANGE, null, lower, upper), + exclusive, + label)); + lower = upper; + exclusive = true; + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioPreviewService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioPreviewService.java index 0539e1903..ab8b0a36b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioPreviewService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioPreviewService.java @@ -1,228 +1,41 @@ package app.bottlenote.alcohols.service; -import static app.bottlenote.alcohols.exception.ScenarioException.invalid; - -import app.bottlenote.alcohols.constant.*; -import app.bottlenote.alcohols.constant.ScenarioTypes.*; -import app.bottlenote.alcohols.domain.AlcoholAttribute; -import app.bottlenote.alcohols.domain.AlcoholCandidateRepository; -import app.bottlenote.alcohols.domain.ScenarioAnswer; -import app.bottlenote.alcohols.domain.ScenarioAnswer.ScenarioContribution; import app.bottlenote.alcohols.domain.ScenarioDefinition; -import app.bottlenote.alcohols.domain.ScenarioDefinition.*; -import app.bottlenote.alcohols.domain.TastingTagGroup; -import app.bottlenote.alcohols.domain.TastingTagGroupRepository; -import app.bottlenote.alcohols.domain.TastingTagGroupTag; -import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria; -import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.*; import app.bottlenote.alcohols.dto.request.AdminScenarioPreviewRequest; -import app.bottlenote.alcohols.dto.response.AdminScenarioResponse.*; -import java.math.BigDecimal; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; +import app.bottlenote.alcohols.dto.response.AdminScenarioResponse.ScenarioPreview; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class ScenarioPreviewService { + private final ScenarioEvaluationService evaluator; private final AdminAlcoholAttributeSearchService searchService; - private final ScenarioDefinitionValidator validator; - private final AlcoholCandidateRepository candidateRepository; - private final TastingTagGroupRepository tastingGroupRepository; - @org.springframework.transaction.annotation.Transactional(readOnly = true) + @Transactional(readOnly = true) public ScenarioPreview preview( - Long groupId, ScenarioDefinition d, AdminScenarioPreviewRequest r) { - var attributes = - candidateRepository.findAttributes(groupId).stream() - .collect(Collectors.toMap(AlcoholAttribute::getId, Function.identity())); - var groups = tastingGroupRepository.findAllWithTags(); - var codes = groups.stream().map(TastingTagGroup::getCode).collect(Collectors.toSet()); - validator.validate(d, groupId, attributes, codes, true); - var questions = - d.questions().stream() - .collect(Collectors.toMap(ScenarioQuestionItem::id, Function.identity())); - var rules = - d.transitions().stream() - .collect(Collectors.groupingBy(ScenarioTransitionItem::fromQuestionId)); - Set submitted = new HashSet<>(); - for (var a : r.answers()) { - if (!submitted.add(a.questionId()) || !questions.containsKey(a.questionId())) - throw invalid("질문 " + a.questionId() + ": 중복 또는 다른 버전의 질문 답변입니다."); - } - List input = r.answers(); - List discarded = List.of(); - if (r.changedQuestionId() != null) { - int index = -1; - for (int i = 0; i < input.size(); i++) - if (input.get(i).questionId().equals(r.changedQuestionId())) index = i; - if (index < 0) throw invalid("changedQuestionId의 답변이 필요합니다."); - discarded = - input.subList(index + 1, input.size()).stream().map(ScenarioAnswer::questionId).toList(); - input = input.subList(0, index + 1); - } - List path = new ArrayList<>(); - List accepted = new ArrayList<>(); - Long next = d.startQuestionId(); - for (var a : input) { - if (!Objects.equals(a.questionId(), next)) - throw invalid("질문 " + a.questionId() + ": 현재 유효 경로의 다음 질문 " + next + "에 대한 답변이 필요합니다."); - var q = questions.get(next); - validateAnswer(q, a); - path.add(next); - accepted.add(a); - var rule = - rules.get(next).stream() - .sorted(Comparator.comparing(ScenarioTransitionItem::priority)) - .filter(t -> matches(t, a)) - .findFirst() - .orElseThrow(() -> invalid("질문 " + a.questionId() + ": 일치하는 명시적 이동 규칙이 없습니다.")); - next = rule.targetKind() == Target.END ? null : rule.targetQuestionId(); - } - if (next != null) path.add(next); - int prefix = accepted.size(); - Long viewed = r.viewQuestionId() == null ? next : r.viewQuestionId(); - if (r.viewQuestionId() != null) { - prefix = path.indexOf(r.viewQuestionId()); - if (prefix < 0) throw invalid("viewQuestionId는 현재 유효 경로의 질문이어야 합니다."); - } - List contributions = new ArrayList<>(); - for (var a : accepted.subList(0, prefix)) - contribute(questions.get(a.questionId()), a, attributes, codes, contributions); - NextQuestion facet = null; - if (viewed != null) { - var q = questions.get(viewed); - facet = new NextQuestion(q.sourceKind(), q.attributeId(), List.of()); - } - List include = new ArrayList<>(); - List filters = new ArrayList<>(); - for (var c : contributions) { - var operator = AttributeSearchOperator.valueOf(c.operator()); - switch (c.source()) { - case ATTRIBUTE -> { - var attribute = attributes.get(c.attributeId()); - if (operator == AttributeSearchOperator.EQ && attribute.getDatatype().isNumeric()) { - include.add(new Condition(c.attributeId(), AttributeSearchOperator.GTE, c.value())); - include.add(new Condition(c.attributeId(), AttributeSearchOperator.LTE, c.value())); - } else include.add(new Condition(c.attributeId(), operator, c.value())); - } - case CATEGORY -> - filters.add( - new AlcoholCandidateCriteria.BaseFilterCriteria( - List.of(AlcoholCategoryGroup.valueOf(c.value())), - null, - null, - List.of(), - false)); - case TASTING_GROUP -> - filters.add( - new AlcoholCandidateCriteria.BaseFilterCriteria( - List.of(), - null, - null, - groups.stream() - .filter(g -> g.getCode().equals(c.value())) - .flatMap(g -> g.getTags().stream()) - .filter(TastingTagGroupTag::isCore) - .map(TastingTagGroupTag::getTastingTagId) - .distinct() - .toList(), - true)); - case ABV -> { - var value = new BigDecimal(c.value()); - filters.add( - new AlcoholCandidateCriteria.BaseFilterCriteria( - List.of(), - operator == AttributeSearchOperator.LTE ? null : value, - operator == AttributeSearchOperator.GTE ? null : value, - List.of(), - false)); - } - } - } - var query = - new AlcoholCandidateCriteria( - groupId, null, include, List.of(), filters, facet, r.page(), r.size()); - var candidates = searchService.findCandidates(query, attributes, groups); + Long groupId, ScenarioDefinition definition, AdminScenarioPreviewRequest request) { + var e = + evaluator.evaluate( + groupId, + definition, + new ScenarioEvaluationService.EvaluationInput( + request.answers(), + request.changedQuestionId(), + request.viewQuestionId(), + request.page(), + request.size(), + Set.of())); return new ScenarioPreview( - List.copyOf(path), - next, - next == null, - List.copyOf(accepted), - discarded, - List.copyOf(contributions), - viewed, - candidates); - } - - private void validateAnswer(ScenarioQuestionItem q, ScenarioAnswer a) { - String at = "질문 " + q.id() + ": "; - if (a.kind() == null) throw invalid(at + "답변 kind가 필요합니다."); - var c = q.inputConfig(); - switch (a.kind()) { - case ANY -> { - if (!c.allowAny() || a.choiceId() != null || a.min() != null || a.max() != null) - throw invalid(at + "허용된 ANY는 다른 답변 값을 포함하지 않습니다."); - } - case CHOICE -> { - if (q.inputKind() != Input.SINGLE_CHOICE - || a.min() != null - || a.max() != null - || c.choices().stream().noneMatch(x -> x.id().equals(a.choiceId()))) - throw invalid(at + "존재하는 choiceId 하나가 필요합니다."); - } - case NUMBER_RANGE -> { - if (q.inputKind() != Input.NUMBER_RANGE - || a.choiceId() != null - || a.min() == null - || a.max() == null - || a.min().precision() > 21 - || a.max().precision() > 21 - || a.min().scale() > 6 - || a.max().scale() > 6 - || a.min().compareTo(a.max()) > 0 - || a.min().compareTo(c.min()) < 0 - || a.max().compareTo(c.max()) > 0 - || a.min().subtract(c.min()).remainder(c.step()).signum() != 0 - || a.max().subtract(c.min()).remainder(c.step()).signum() != 0) - throw invalid(at + "설정한 min/max/step에 맞는 숫자 범위가 필요합니다."); - } - } - } - - private boolean matches(ScenarioTransitionItem t, ScenarioAnswer a) { - return t.predicate().matches(a.kind(), a.choiceId(), a.min(), a.max()); - } - - private void contribute( - ScenarioQuestionItem q, - ScenarioAnswer a, - Map attrs, - Set codes, - List out) { - if (a.kind() == AnswerKind.ANY) return; - if (a.kind() == AnswerKind.CHOICE) { - var choice = - q.inputConfig().choices().stream() - .filter(c -> c.id().equals(a.choiceId())) - .findFirst() - .orElseThrow(); - var value = - validator.choiceValue( - q, - q.attributeId() == null ? null : attrs.get(q.attributeId()), - codes, - choice.value()); - out.add(new ScenarioContribution(q.id(), q.sourceKind(), q.attributeId(), "EQ", value)); - } else { - out.add( - new ScenarioContribution( - q.id(), q.sourceKind(), q.attributeId(), "GTE", a.min().toPlainString())); - out.add( - new ScenarioContribution( - q.id(), q.sourceKind(), q.attributeId(), "LTE", a.max().toPlainString())); - } + e.path(), + e.nextQuestionId(), + e.completed(), + e.answers(), + e.discardedQuestionIds(), + e.contributions(), + e.viewedQuestionId(), + searchService.findCandidates(e.query(), e.attributes(), e.tastingGroups())); } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioService.java new file mode 100644 index 000000000..b40ba2358 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioService.java @@ -0,0 +1,10 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.dto.request.ScenarioEvaluateRequest; +import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse; + +public interface ScenarioService { + ScenarioEvaluationResponse get(Long scenarioId); + + ScenarioEvaluationResponse evaluate(Long scenarioId, ScenarioEvaluateRequest request); +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeServiceTest.java index 004e8f7d0..08a7b8682 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholAttributeServiceTest.java @@ -539,6 +539,23 @@ public List findPage(AlcoholCandidateCriteria query) { throw unexpectedCall(); } + @Override + public List findQuartiles( + AlcoholCandidateCriteria query, java.math.BigDecimal min, java.math.BigDecimal max) { + throw unexpectedCall(); + } + + @Override + public List countAlternatives( + AlcoholCandidateCriteria base, List alternatives) { + throw unexpectedCall(); + } + + @Override + public List findAfter(AlcoholCandidateCriteria query, Long afterId, int limit) { + throw unexpectedCall(); + } + @Override public List findMappings(Long groupId, List alcoholIds) { throw unexpectedCall(); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioDtoContractTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioDtoContractTest.java index 64cc4f5f6..7d139d1a7 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioDtoContractTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioDtoContractTest.java @@ -85,7 +85,7 @@ class ScenarioDtoContractTest { () -> mapper.readValue( "{\"source\":" + json + "}", - app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.NextQuestion + app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.NextQuestionCriteria .class)) .isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class); assertThatThrownBy( diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java new file mode 100644 index 000000000..e5723fd0e --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java @@ -0,0 +1,106 @@ +package app.bottlenote.alcohols.service; + +import static org.assertj.core.api.Assertions.*; + +import app.bottlenote.alcohols.constant.ScenarioTypes.AnswerKind; +import app.bottlenote.alcohols.domain.ScenarioAnswer; +import app.bottlenote.alcohols.domain.ScenarioSelection; +import app.bottlenote.alcohols.exception.ScenarioException; +import app.bottlenote.global.pagination.CursorProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.*; + +@Tag("unit") +@DisplayName("선택지 서명을 검증할 때 조건과 선행 답변을 보존한다") +class ScenarioOptionCodecTest { + private final CursorProperties keys = new CursorProperties(); + private final ScenarioOptionCodec codec = new ScenarioOptionCodec(keys, new ObjectMapper()); + private final ScenarioSelection selection = + new ScenarioSelection( + new ScenarioAnswer( + 11L, AnswerKind.NUMBER_RANGE, null, new BigDecimal("13000"), new BigDecimal("15000")), + true, + "13000원 초과 ~ 15000원 이하"); + + @BeforeEach + void setup() { + keys.setCurrentKeyId("v1"); + keys.setCurrentSecret("unit-test-key-with-sufficient-length"); + } + + @Test + @DisplayName("시간 제한 없이 저장한 선택지를 읽을 때 배타 경계와 문구를 복원한다") + void preservesPredicateWithoutCursorExpiry() { + String context = codec.context(1L, 2L, List.of()); + assertThat(codec.decode(codec.encode(context, selection), context, 11L)).isEqualTo(selection); + } + + @Test + @DisplayName("시나리오나 버전 또는 질문이나 선행 답변이 달라지면 거절한다") + void bindsToScenarioVersionQuestionAndPrefix() { + String context = codec.context(1L, 2L, List.of()); + String token = codec.encode(context, selection); + for (String other : + List.of( + codec.context(2L, 2L, List.of()), + codec.context(1L, 3L, List.of()), + codec.context(1L, 2L, List.of(selection)))) { + assertThatThrownBy(() -> codec.decode(token, other, 11L)) + .isInstanceOf(ScenarioException.class); + } + assertThatThrownBy(() -> codec.decode(token, context, 12L)) + .isInstanceOf(ScenarioException.class); + assertThatThrownBy(() -> codec.decode(token + "a", context, 11L)) + .isInstanceOf(ScenarioException.class); + } + + @Test + @DisplayName("키를 교체할 때 이전 키는 허용하고 폐기한 키는 재선택을 요구한다") + void respectsKeyRotation() { + String context = codec.context(1L, 2L, List.of()); + String token = codec.encode(context, selection); + keys.setPreviousKeyId(keys.getCurrentKeyId()); + keys.setPreviousSecret(keys.getCurrentSecret()); + keys.setCurrentKeyId("v2"); + keys.setCurrentSecret("rotated-test-key-with-sufficient-length"); + assertThat(codec.decode(token, context, 11L)).isEqualTo(selection); + assertThat(codec.decode(codec.encode(context, selection), context, 11L)).isEqualTo(selection); + keys.setPreviousKeyId(null); + keys.setPreviousSecret(null); + assertThatThrownBy(() -> codec.decode(token, context, 11L)) + .isInstanceOf(ScenarioException.class); + } + + @Test + @DisplayName("소수 표기만 다른 답변은 같은 조건으로 정규화한다") + void normalizesDecimalScale() { + var scaled = + new ScenarioSelection( + new ScenarioAnswer( + 11L, + AnswerKind.NUMBER_RANGE, + null, + new BigDecimal("13000.00"), + new BigDecimal("15000.0")), + true, + selection.label()); + assertThat(codec.context(1L, 2L, List.of(scaled))) + .isEqualTo(codec.context(1L, 2L, List.of(selection))); + } + + @Test + @DisplayName("문구만 달라지면 선행 답변 조건은 같은 문맥으로 평가한다") + void contextUsesPredicatesRatherThanLabels() { + var renamed = new ScenarioSelection(selection.answer(), selection.minExclusive(), "다른 문구"); + assertThat(codec.context(1L, 2L, List.of(selection))) + .isEqualTo(codec.context(1L, 2L, List.of(renamed))); + assertThat(codec.context(1L, 2L, List.of(selection))) + .isNotEqualTo( + codec.context( + 1L, + 2L, + List.of(new ScenarioSelection(selection.answer(), false, selection.label())))); + } +} diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/ScenarioController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/ScenarioController.java new file mode 100644 index 000000000..093feefea --- /dev/null +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/ScenarioController.java @@ -0,0 +1,39 @@ +package app.bottlenote.alcohols.controller; + +import static app.bottlenote.global.annotation.SecurityPolicy.AuthType.OPTIONAL_AUTH; + +import app.bottlenote.alcohols.controller.docs.ScenarioApiDocs; +import app.bottlenote.alcohols.dto.request.ScenarioEvaluateRequest; +import app.bottlenote.alcohols.service.ScenarioService; +import app.bottlenote.global.annotation.SecurityPolicy; +import app.bottlenote.global.data.response.GlobalResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/scenarios") +@RequiredArgsConstructor +@Validated +@SecurityPolicy(auth = OPTIONAL_AUTH) +@ScenarioApiDocs.ApiTag +public class ScenarioController { + private final ScenarioService service; + + @GetMapping("/{scenarioId}") + @ScenarioApiDocs.Get + public ResponseEntity get(@PathVariable @Positive Long scenarioId) { + return GlobalResponse.ok(service.get(scenarioId)); + } + + @PostMapping("/{scenarioId}/evaluate") + @ScenarioApiDocs.Evaluate + public ResponseEntity getEvaluation( + @PathVariable @Positive Long scenarioId, + @RequestBody @Valid ScenarioEvaluateRequest request) { + return GlobalResponse.ok(service.evaluate(scenarioId, request)); + } +} diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/ScenarioApiDocs.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/ScenarioApiDocs.java new file mode 100644 index 000000000..1f023e819 --- /dev/null +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/ScenarioApiDocs.java @@ -0,0 +1,67 @@ +package app.bottlenote.alcohols.controller.docs; + +import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.*; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.lang.annotation.*; + +public final class ScenarioApiDocs { + private ScenarioApiDocs() {} + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @Tag(name = "사용자 시나리오") + public @interface ApiTag {} + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Operation( + summary = "게시된 시나리오와 첫 질문을 조회한다", + description = "활성 게시본만 제공합니다. 후보는 매 요청 현재 데이터로 계산합니다. 로그인 없이 사용할 수 있으며 참여 이벤트는 기록하지 않습니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "시나리오와 첫 질문", + content = + @Content(schema = @Schema(implementation = ScenarioEvaluationResponse.class))), + @ApiResponse( + responseCode = "404", + description = "없거나 비활성 또는 게시본 없는 시나리오", + content = @Content) + }) + public @interface Get {} + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Operation( + summary = "전체 답변으로 질문 또는 최신 추천 결과를 평가한다", + description = + """ + 조회한 versionId와 순서대로 선택한 {questionId, optionId} 전체를 전달합니다. + QUESTION은 다음 질문, RESULT는 현재 답변만 적용한 결과입니다. QUESTION에서 END에 도달해도 결과를 반환합니다. + 과거 질문은 viewQuestionId로 조회하며 그 질문 직전까지의 답변만 후보에 적용합니다. + 답변을 바꾸면 변경한 질문까지의 답변만 제출하고 이후 답변은 브라우저에서 폐기합니다. + optionId는 서버가 발급한 값을 그대로 보관합니다. 분포가 바뀌어도 숫자 구간 의미는 유지하며 후보만 다시 계산합니다. + 답변이나 결과를 서버에 저장하지 않고 캠페인 참여 이벤트도 기록하지 않습니다. + 결과는 alcoholId 오름차순 keyset이며 candidates.pagination.nextCursor로 다음 페이지를 요청합니다. + 페이징 중 관리 데이터가 변경되면 현재 수와 목록이 달라질 수 있습니다. 커서는 1시간 후 만료됩니다. + 버전 충돌은 재조회하고, 선택지 검증 실패 또는 서명 키 폐기는 해당 질문부터 다시 선택해야 합니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "질문 또는 추천 결과", + content = + @Content(schema = @Schema(implementation = ScenarioEvaluationResponse.class))), + @ApiResponse(responseCode = "400", description = "답변·선택지·경로·커서 검증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = "제공할 수 없는 시나리오", content = @Content), + @ApiResponse( + responseCode = "409", + description = "현재 활성 게시 버전과 요청 버전 불일치", + content = @Content), + @ApiResponse(responseCode = "410", description = "결과 커서 만료", content = @Content) + }) + public @interface Evaluate {} +} diff --git a/bottlenote-product-api/src/main/java/app/global/config/OpenApiConfig.java b/bottlenote-product-api/src/main/java/app/global/config/OpenApiConfig.java index 7c1d8587f..11895c7de 100644 --- a/bottlenote-product-api/src/main/java/app/global/config/OpenApiConfig.java +++ b/bottlenote-product-api/src/main/java/app/global/config/OpenApiConfig.java @@ -35,6 +35,7 @@ public class OpenApiConfig { tag("둘러보기", "원하는 기준으로 콘텐츠를 둘러본다"), tag("수입 정보", "식약처 수입 주류와 수입사를 조회한다"), tag("큐레이션", "기획으로 엮은 위스키 모음과 그 구성 명세를 조회한다"), + tag("사용자 시나리오", "게시된 질문의 답변을 검증하고 현재 추천 후보를 계산한다"), tag("캠페인 콘텐츠", "FE가 배포한 캠페인 콘텐츠의 참여 이벤트를 기록한다"), tag("리뷰", "리뷰와 댓글을 작성·조회하고 좋아요를 누른다"), tag("마이페이지", "내가 남긴 리뷰·별점·찜과 활동 기록을 모아 본다"), diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/ScenarioControllerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/ScenarioControllerIntegrationTest.java new file mode 100644 index 000000000..7c2bf18c9 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/ScenarioControllerIntegrationTest.java @@ -0,0 +1,413 @@ +package app.bottlenote.alcohols.integration; + +import static app.bottlenote.alcohols.fixture.AlcoholScenarioTestFactory.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.constant.AlcoholQuestionSource; +import app.bottlenote.alcohols.constant.ScenarioTypes.Operator; +import app.bottlenote.alcohols.domain.*; +import app.bottlenote.alcohols.domain.ScenarioDefinition.ScenarioStageItem; +import app.bottlenote.alcohols.service.ScenarioDefinitionStore; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.assertj.MvcTestResult; +import org.springframework.transaction.support.TransactionTemplate; + +@Tag("integration") +@DisplayName("사용자 시나리오를 실행할 때 현재 후보와 무상태 답변 계약을 검증한다") +class ScenarioControllerIntegrationTest extends IntegrationTestSupport { + @Autowired JdbcTemplate jdbc; + @Autowired TransactionTemplate transactions; + @Autowired AlcoholScenarioRepository scenarios; + @Autowired AlcoholScenarioVersionRepository versions; + @Autowired ScenarioDefinitionStore definitions; + private static final long GROUP = 970001, PRICE = 970001; + private long scenarioId, versionId; + + @BeforeEach + void seed() { + jdbc.update("INSERT INTO alcohol_attribute_groups(id,name) VALUES(?, '사용자 시나리오')", GROUP); + jdbc.update( + "INSERT INTO alcohol_attributes(id,group_id,attribute_key,display_name,attribute_type,datatype,allow_multiple_values) VALUES(?,?,'scenario_price','가격','PRICE','DOUBLE',true)", + PRICE, + GROUP); + jdbc.update( + "INSERT INTO tasting_tag_groups(id,code,name,display_order) VALUES(970001,'PEAT','피트',0)"); + jdbc.update("INSERT INTO tasting_tags(id,kor_name,eng_name) VALUES(970001,'피트','Peat')"); + jdbc.update( + "INSERT INTO tasting_tag_group_tags(group_id,tasting_tag_id,role) VALUES(970001,970001,'CORE')"); + for (int i = 1; i <= 8; i++) { + jdbc.update( + "INSERT INTO alcohols(id,kor_name,eng_name,type,category_group,abv,kor_category,eng_category,age,cask,volume) VALUES(?,?,?,'WHISKY',?,?,'위스키','Whisky','12','BOURBON','700')", + 980000 + i, + "후보 " + i, + "Candidate " + i, + i <= 4 ? "SINGLE_MALT" : "BOURBON", + 39 + i + "%"); + jdbc.update( + "INSERT INTO alcohol_attribute_mappings(alcohol_id,attribute_id,attribute_value) VALUES(?,?,?)", + 980000 + i, + PRICE, + i == 3 ? "2000.5" : String.valueOf(i * 1000)); + if (i % 2 == 0) + jdbc.update( + "INSERT INTO alcohols_tasting_tags(alcohol_id,tasting_tag_id) VALUES(?,970001)", + 980000 + i); + } + transactions.executeWithoutResult( + status -> { + var s = scenarios.saveScenario(AlcoholAttributeScenario.create(GROUP, 1, "추천")); + var v = versions.save(AlcoholAttributeScenarioVersion.create(s.getId(), 1)); + var d = + new ScenarioDefinition( + 1, + -11L, + List.of( + new ScenarioStageItem(-1L, 1), + new ScenarioStageItem(-2L, 2), + new ScenarioStageItem(-3L, 3), + new ScenarioStageItem(-4L, 4)), + List.of( + number( + -11, -1, 1, "가격", AlcoholQuestionSource.ATTRIBUTE, PRICE, 0, 10000, 1000), + choice( + -21, + -2, + 1, + "종류", + AlcoholQuestionSource.CATEGORY, + null, + "SINGLE_MALT", + "BOURBON"), + number(-31, -3, 1, "도수", AlcoholQuestionSource.ABV, null, 0, 100, 1), + choice(-41, -4, 1, "향", AlcoholQuestionSource.TASTING_GROUP, null, "PEAT")), + List.of( + rule(-101, -11, 1, Operator.ALWAYS, null, -21L), + rule(-102, -21, 1, Operator.EQ, "BOURBON", null), + rule(-103, -21, 2, Operator.ALWAYS, null, -31L), + rule(-104, -31, 1, Operator.ALWAYS, null, -41L), + rule(-105, -41, 1, Operator.ALWAYS, null, null))); + definitions.store(v, d); + v.publish(); + versions.save(v); + s.activate(v.getId()); + scenarios.saveScenario(s); + scenarioId = s.getId(); + versionId = v.getId(); + }); + } + + private String url() { + return "/api/v1/scenarios/" + scenarioId; + } + + private JsonNode data(MvcTestResult result) throws Exception { + result.assertThat().hasStatusOk(); + return mapper.readTree(result.getResponse().getContentAsString()).get("data"); + } + + private JsonNode start() throws Exception { + return data(mockMvcTester.get().uri(url()).exchange()); + } + + private Map answer(JsonNode question, int index) { + return Map.of( + "questionId", + question.get("questionId").asLong(), + "optionId", + question.at("/options/" + index + "/optionId").asText()); + } + + private Map any(JsonNode question) { + return answer(question, question.get("options").size() - 1); + } + + private MvcTestResult evaluate(List> answers, Map extras) + throws Exception { + var body = new HashMap(); + body.put("versionId", versionId); + body.put("answers", answers); + body.put("mode", "QUESTION"); + body.putAll(extras); + return mockMvcTester + .post() + .uri(url() + "/evaluate") + .with(csrf()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(body)) + .exchange(); + } + + @Test + @DisplayName("로그인 없이 시작할 때 게시본과 숫자 선택지를 반환한다") + void startsPublishedScenario() throws Exception { + var s = start(); + assertThat(s.get("versionId").asLong()).isEqualTo(versionId); + assertThat(s.get("candidateCount").asLong()).isEqualTo(8); + assertThat(s.get("state").asText()).isEqualTo("QUESTION"); + assertThat(s.at("/question/options").size()).isBetween(2, 5); + assertThat(s.at("/candidates/content").isEmpty()).isTrue(); + assertThat(s.toString()) + .doesNotContain("attributeKey", "transitions", "lockVersion", "createPrincipal"); + } + + @Test + @DisplayName("숫자 구간을 선택할 때 소수 후보가 경계 사이에서 빠지거나 겹치지 않는다") + void numericIntervalsPartitionCandidates() throws Exception { + var question = start().get("question"); + Set ids = new HashSet<>(); + long sum = 0; + for (int i = 0; i < question.get("options").size() - 1; i++) { + var result = data(evaluate(List.of(answer(question, i)), Map.of("mode", "RESULT"))); + assertThat(result.get("candidateCount").asLong()) + .isEqualTo(question.at("/options/" + i + "/candidateCount").asLong()); + sum += result.get("candidateCount").asLong(); + for (var item : result.at("/candidates/content")) + assertThat(ids.add(item.get("alcoholId").asLong())).isTrue(); + } + assertThat(sum).isEqualTo(8); + assertThat(ids).hasSize(8); + } + + @Test + @DisplayName("저장한 숫자 답변으로 다시 평가할 때 구간을 유지하고 변경된 연결을 바로 반영한다") + void restoredAnswerUsesCurrentMappingsWithoutChangingRange() throws Exception { + var q = start().get("question"); + var a = answer(q, 0); + var before = data(evaluate(List.of(a), Map.of("mode", "RESULT"))); + jdbc.update( + "UPDATE alcohol_attribute_mappings SET attribute_value='9000' WHERE alcohol_id=980001 AND attribute_id=?", + PRICE); + var after = data(evaluate(List.of(a), Map.of("mode", "RESULT"))); + assertThat(after.get("candidateCount").asLong()) + .isEqualTo(before.get("candidateCount").asLong() - 1); + assertThat(after.at("/answers/0/label")).isEqualTo(before.at("/answers/0/label")); + } + + @Test + @DisplayName("게시 상태나 활성 버전이 바뀌면 다음 요청에서 즉시 반영한다") + void visibilityAndVersionChangesApplyImmediately() throws Exception { + start(); + jdbc.update("UPDATE alcohol_attribute_scenarios SET is_enabled=false WHERE id=?", scenarioId); + mockMvcTester.get().uri(url()).exchange().assertThat().hasStatus(HttpStatus.NOT_FOUND); + evaluate(List.of(), Map.of()).assertThat().hasStatus(HttpStatus.NOT_FOUND); + jdbc.update("UPDATE alcohol_attribute_scenarios SET is_enabled=true WHERE id=?", scenarioId); + start(); + evaluate(List.of(), Map.of("versionId", versionId + 1)) + .assertThat() + .hasStatus(HttpStatus.CONFLICT); + jdbc.update( + "UPDATE alcohol_attribute_scenario_versions SET status='DRAFT' WHERE id=?", versionId); + mockMvcTester.get().uri(url()).exchange().assertThat().hasStatus(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("정의상 종료에 도달하면 중간 종료와 구분해 결과를 반환한다") + void branchesAndEnds() throws Exception { + var a = any(start().get("question")); + var q = data(evaluate(List.of(a), Map.of())).get("question"); + var result = data(evaluate(List.of(a, answer(q, 1)), Map.of())); + assertThat(result.get("state").asText()).isEqualTo("SCENARIO_END"); + assertThat(result.get("candidateCount").asLong()).isEqualTo(4); + assertThat(data(evaluate(List.of(a), Map.of("mode", "RESULT"))).get("state").asText()) + .isEqualTo("USER_FINISHED"); + } + + @Test + @DisplayName("과거 질문을 볼 때 답변은 유지하고 후보에는 직전 답변까지만 적용한다") + void historicalViewAndEarlyFinishUsePrefix() throws Exception { + var first = start().get("question"); + var a = any(first); + var second = data(evaluate(List.of(a), Map.of())).get("question"); + var b = answer(second, 0); + var past = + data(evaluate(List.of(a, b), Map.of("viewQuestionId", first.get("questionId").asLong()))); + assertThat(past.get("candidateCount").asLong()).isEqualTo(8); + assertThat(past.get("answers").size()).isEqualTo(2); + assertThat(past.at("/answers/0/applied").asBoolean()).isFalse(); + var result = + data( + evaluate( + List.of(a, b), + Map.of("viewQuestionId", second.get("questionId").asLong(), "mode", "RESULT"))); + assertThat(result.get("candidateCount").asLong()).isEqualTo(8); + assertThat(data(evaluate(List.of(a), Map.of())).at("/question/questionId")) + .isEqualTo(second.get("questionId")); + } + + @Test + @DisplayName("도수와 맛 그룹을 선택할 때 후보 수와 실제 결과가 일치한다") + void abvAndTastingGroupMatchResults() throws Exception { + var a = any(start().get("question")); + var q2 = data(evaluate(List.of(a), Map.of())).get("question"); + var b = answer(q2, 0); + var q3 = data(evaluate(List.of(a, b), Map.of())).get("question"); + for (int i = 0; i < q3.get("options").size() - 1; i++) { + var part = data(evaluate(List.of(a, b, answer(q3, i)), Map.of("mode", "RESULT"))); + assertThat(part.get("candidateCount").asLong()) + .isEqualTo(q3.at("/options/" + i + "/candidateCount").asLong()); + } + var c = any(q3); + var q4 = data(evaluate(List.of(a, b, c), Map.of())).get("question"); + assertThat(q4.at("/options/0/candidateCount").asLong()).isEqualTo(2); + var done = data(evaluate(List.of(a, b, c, answer(q4, 0)), Map.of())); + assertThat(done.get("candidateCount").asLong()).isEqualTo(2); + assertThat(done.get("state").asText()).isEqualTo("SCENARIO_END"); + jdbc.update("DELETE FROM alcohols_tasting_tags WHERE alcohol_id=980002"); + assertThat( + data(evaluate(List.of(a, b, c, answer(q4, 0)), Map.of())) + .get("candidateCount") + .asLong()) + .isEqualTo(1); + } + + @Test + @DisplayName("선택지나 질문 순서를 위조하면 답변을 거절한다") + void rejectsTamperingDuplicatesAndInvalidPath() throws Exception { + var first = start().get("question"); + var a = any(first); + var second = data(evaluate(List.of(a), Map.of())).get("question"); + var b = answer(second, 0); + evaluate(List.of(b), Map.of()).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + evaluate(List.of(a, a), Map.of()).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + evaluate( + List.of(Map.of("questionId", a.get("questionId"), "optionId", a.get("optionId") + "x")), + Map.of()) + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST); + evaluate(List.of(a, b), Map.of("viewQuestionId", 999999)) + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST); + var changed = answer(first, 0); + evaluate(List.of(changed, b), Map.of()).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("결과를 추가 조회할 때 중복 없이 순회하고 다른 조건의 커서를 거절한다") + void keysetPaginationIsBoundToAnswerContext() throws Exception { + var first = data(evaluate(List.of(), Map.of("mode", "RESULT", "size", 3))); + String cursor = first.at("/candidates/pagination/nextCursor").asText(); + var second = data(evaluate(List.of(), Map.of("mode", "RESULT", "size", 3, "cursor", cursor))); + assertThat(first.at("/candidates/content/2/alcoholId").asLong()) + .isLessThan(second.at("/candidates/content/0/alcoholId").asLong()); + evaluate(List.of(), Map.of("mode", "RESULT", "size", 4, "cursor", cursor)) + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST); + evaluate( + List.of(any(start().get("question"))), + Map.of("mode", "RESULT", "size", 3, "cursor", cursor)) + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST); + evaluate(List.of(), Map.of("cursor", cursor)).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("후보가 없거나 단일 값이면 빈 구간을 발급하지 않는다") + void handlesEmptyAndIdenticalDistribution() throws Exception { + jdbc.update( + "UPDATE alcohol_attribute_mappings SET attribute_value='1000' WHERE attribute_id=?", PRICE); + var q = start().get("question"); + assertThat(q.get("options").size()).isEqualTo(2); + jdbc.update("DELETE FROM alcohol_attribute_mappings WHERE attribute_id=?", PRICE); + var s = start(); + assertThat(s.get("candidateCount").asLong()).isZero(); + assertThat(s.at("/question/options").size()).isEqualTo(1); + assertThat(s.at("/question/options/0/selectable").asBoolean()).isFalse(); + } + + @Test + @DisplayName("숫자 선택형은 값의 소수 표기와 무관하게 정확히 일치한다") + void numericChoiceMatchesExactly() throws Exception { + long questionId = start().at("/question/questionId").asLong(); + String config = + "{\"allowAny\":true,\"choices\":[{\"id\":\"two\",\"label\":\"이천 원\",\"value\":2000.0}]}"; + jdbc.update( + "UPDATE alcohol_attribute_scenario_questions SET input_kind='SINGLE_CHOICE', input_config=? WHERE id=?", + config, + questionId); + var question = start().get("question"); + assertThat(question.at("/options/0/candidateCount").asLong()).isEqualTo(1); + assertThat( + data(evaluate(List.of(answer(question, 0)), Map.of("mode", "RESULT"))) + .get("candidateCount") + .asLong()) + .isEqualTo(1); + } + + @Test + @DisplayName("같은 속성의 이전 범위를 만족하는 값만 다음 숫자 선택지에 집계한다") + void repeatedAttributeUsesSameMappingValue() throws Exception { + jdbc.update( + "INSERT INTO alcohol_attribute_mappings(alcohol_id,attribute_id,attribute_value) VALUES(980001,?,'9000')", + PRICE); + var first = start().get("question"); + var a = answer(first, 0); + long secondId = data(evaluate(List.of(a), Map.of())).at("/question/questionId").asLong(); + String config = "{\"allowAny\":true,\"min\":0,\"max\":10000,\"step\":1000}"; + // 이 테스트의 두 번째 질문은 숫자 질문이므로 선택형 분기를 제거한다. + jdbc.update( + "DELETE FROM alcohol_attribute_scenario_transitions WHERE from_question_id=? AND priority=1", + secondId); + jdbc.update( + "UPDATE alcohol_attribute_scenario_questions SET source_kind='ATTRIBUTE', attribute_id=?, input_kind='NUMBER_RANGE',input_config=? WHERE id=?", + PRICE, + config, + secondId); + var q = data(evaluate(List.of(a), Map.of())).get("question"); + long total = 0; + for (int i = 0; i < q.get("options").size() - 1; i++) { + var result = data(evaluate(List.of(a, answer(q, i)), Map.of("mode", "RESULT"))); + long count = q.at("/options/" + i + "/candidateCount").asLong(); + assertThat(result.get("candidateCount").asLong()).isEqualTo(count); + total += count; + } + assertThat(total).isEqualTo(first.at("/options/0/candidateCount").asLong()); + } + + @Test + @DisplayName("다른 시나리오의 선택지를 거절하고 참여 이벤트를 자동 저장하지 않는다") + void isolatesScenarioAndDoesNotRecordParticipation() throws Exception { + var a = any(start().get("question")); + data(evaluate(List.of(a), Map.of("mode", "RESULT"))); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM campaign_content_events", Long.class)) + .isZero(); + long other = + transactions.execute( + status -> { + var s = scenarios.saveScenario(AlcoholAttributeScenario.create(GROUP, 2, "다른 추천")); + var v = versions.save(AlcoholAttributeScenarioVersion.create(s.getId(), 1)); + definitions.store( + v, definitions.read(versions.findById(versionId).orElseThrow()).forClone()); + v.publish(); + versions.save(v); + s.activate(v.getId()); + scenarios.saveScenario(s); + return s.getId(); + }); + scenarioId = other; + versionId = + jdbc.queryForObject( + "SELECT active_version_id FROM alcohol_attribute_scenarios WHERE id=?", + Long.class, + other); + start(); + evaluate(List.of(a), Map.of()).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("요청 필드가 빠지거나 범위를 벗어나면 400을 반환한다") + void validatesPublicRequest() throws Exception { + evaluate(List.of(), Map.of("size", 101)).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + evaluate(List.of(), Map.of("mode", "UNKNOWN")).assertThat().hasStatus(HttpStatus.BAD_REQUEST); + evaluate(List.of(Map.of("questionId", -1, "optionId", "x")), Map.of()) + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/global/integration/ScenarioOpenApiContractIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/global/integration/ScenarioOpenApiContractIntegrationTest.java new file mode 100644 index 000000000..b61238187 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/global/integration/ScenarioOpenApiContractIntegrationTest.java @@ -0,0 +1,49 @@ +package app.bottlenote.global.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.*; + +@Tag("integration") +@DisplayName("사용자 시나리오의 실행 중 OpenAPI 계약을 확인한다") +class ScenarioOpenApiContractIntegrationTest extends OpenApiSpecTestSupport { + @Test + @DisplayName("조회와 평가 API는 사용자 질문과 서로 다른 요청·응답 답변 스키마를 문서화한다") + void publishesUserFacingContract() { + var spec = fetchSpec(); + for (String endpoint : + java.util.List.of( + "GET /api/v1/scenarios/{scenarioId}", "POST /api/v1/scenarios/{scenarioId}/evaluate")) { + var operation = + operationsOf(spec).stream() + .filter(o -> o.endpoint().equals(endpoint)) + .findFirst() + .orElseThrow(); + var data = resolve(spec, operation.successSchema().at("/properties/data")); + assertThat(propertyNamesOf(data)) + .contains( + "scenarioId", + "versionId", + "question", + "answers", + "candidateCount", + "candidates", + "state"); + var question = resolve(spec, data.at("/properties/question")); + var option = resolve(spec, question.at("/properties/options/items")); + assertThat(propertyNamesOf(option)) + .contains("optionId", "label", "candidateCount", "selectable", "minExclusive"); + var answer = resolve(spec, data.at("/properties/answers/items")); + assertThat(propertyNamesOf(answer)).contains("questionId", "optionId", "label", "applied"); + } + var request = + resolve( + spec, + spec.at( + "/paths/~1api~1v1~1scenarios~1{scenarioId}~1evaluate/post/requestBody/content/application~1json/schema")); + var answer = resolve(spec, request.at("/properties/answers/items")); + assertThat(propertyNamesOf(answer)).containsExactlyInAnyOrder("questionId", "optionId"); + assertThat(propertyNamesOf(request)) + .contains("versionId", "answers", "mode", "viewQuestionId", "cursor", "size"); + } +} From a82132d603b5200565a1e686b4be22e24c043b2b Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 24 Oct 2026 13:39:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=84=A0=ED=83=9D=EC=A7=80=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EA=B3=BC=20=EB=82=B4=EB=B6=80=20=ED=83=80=EC=9E=85=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alcohols/domain/ScenarioAnswer.java | 2 + .../alcohols/domain/ScenarioSelection.java | 4 - .../service/DefaultScenarioService.java | 105 ++++++++++++++++-- .../service/ScenarioEvaluationService.java | 4 +- .../alcohols/service/ScenarioOptionCodec.java | 10 +- .../service/ScenarioOptionService.java | 98 ---------------- .../service/ScenarioOptionCodecTest.java | 25 +++-- 7 files changed, 119 insertions(+), 129 deletions(-) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioAnswer.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioAnswer.java index 9330e6d50..b5ad7bdb1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioAnswer.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioAnswer.java @@ -20,6 +20,8 @@ public void rejectUnknown(String key, JsonNode value) { throw ScenarioException.invalid("답변에 미지원 필드가 있습니다: " + key); } + public record Selection(ScenarioAnswer answer, boolean minExclusive, String label) {} + public record ScenarioContribution( Long questionId, AlcoholQuestionSource source, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java deleted file mode 100644 index 81eb54350..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/ScenarioSelection.java +++ /dev/null @@ -1,4 +0,0 @@ -package app.bottlenote.alcohols.domain; - -/** 서명한 선택 조건은 후보 분포가 달라져도 같은 의미로 평가한다. */ -public record ScenarioSelection(ScenarioAnswer answer, boolean minExclusive, String label) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java index 082637eb3..f2cfaf312 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultScenarioService.java @@ -2,8 +2,10 @@ import static app.bottlenote.alcohols.exception.ScenarioExceptionCode.*; -import app.bottlenote.alcohols.constant.ScenarioTypes.Status; +import app.bottlenote.alcohols.constant.ScenarioTypes.*; import app.bottlenote.alcohols.domain.*; +import app.bottlenote.alcohols.domain.ScenarioAnswer.Selection; +import app.bottlenote.alcohols.domain.ScenarioDefinition.ScenarioQuestionItem; import app.bottlenote.alcohols.dto.request.ScenarioEvaluateRequest; import app.bottlenote.alcohols.dto.request.ScenarioEvaluateRequest.Mode; import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse; @@ -11,6 +13,8 @@ import app.bottlenote.alcohols.exception.ScenarioException; import app.bottlenote.alcohols.service.ScenarioEvaluationService.*; import app.bottlenote.global.pagination.*; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.*; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -25,8 +29,7 @@ public class DefaultScenarioService implements ScenarioService { private final ScenarioDefinitionStore definitions; private final ScenarioEvaluationService evaluator; private final AlcoholCandidateRepository candidates; - private final ScenarioOptionCodec options; - private final ScenarioOptionService optionService; + private final ScenarioOptionCodec optionCodec; private final HmacCursorCodec cursors; @Override @@ -63,24 +66,26 @@ private Published published(Long id) { private ScenarioEvaluationResponse evaluate(Published p, ScenarioEvaluateRequest r) { Long id = p.scenario().getId(), version = p.version().getId(); - List selections = new ArrayList<>(); + List selections = new ArrayList<>(); for (var answer : r.answers()) { selections.add( - options.decode( - answer.optionId(), options.context(id, version, selections), answer.questionId())); + optionCodec.decode( + answer.optionId(), + optionCodec.context(id, version, selections), + answer.questionId())); } var e = evaluator.evaluate( p.scenario().getGroupId(), p.definition(), new EvaluationInput( - selections.stream().map(ScenarioSelection::answer).toList(), + selections.stream().map(Selection::answer).toList(), null, r.viewQuestionId(), 0, r.size(), selections.stream() - .filter(ScenarioSelection::minExclusive) + .filter(Selection::minExclusive) .map(s -> s.answer().questionId()) .collect(Collectors.toSet()))); int applied = @@ -101,8 +106,8 @@ private ScenarioEvaluationResponse evaluate(Published p, ScenarioEvaluateRequest new ScenarioQuestion( q.id(), q.title(), - optionService.options( - q, e, options.context(id, version, selections.subList(0, applied)))); + createOptions( + q, e, optionCodec.context(id, version, selections.subList(0, applied)))); } List answers = new ArrayList<>(); for (int i = 0; i < selections.size(); i++) { @@ -130,7 +135,7 @@ private ScenarioEvaluationResponse evaluate(Published p, ScenarioEvaluateRequest private KeysetPageResponse> resultPage( Published published, ScenarioEvaluateRequest r, - List applied, + List applied, Evaluation e, State state) { if (state == State.QUESTION) { @@ -139,7 +144,8 @@ private KeysetPageResponse> resultPage( } String context = "scenario-results-v1:" - + options.context(published.scenario().getId(), published.version().getId(), applied) + + optionCodec.context( + published.scenario().getId(), published.version().getId(), applied) + ":" + r.size(); Long after = @@ -166,6 +172,81 @@ private KeysetPageResponse> resultPage( slice.pagination()); } + private List createOptions(ScenarioQuestionItem q, Evaluation e, String context) { + List selections = new ArrayList<>(); + if (q.inputKind() == Input.NUMBER_RANGE) { + numericOptions(q, e, selections); + } else { + for (var choice : q.inputConfig().choices()) + selections.add( + new Selection( + new ScenarioAnswer(q.id(), AnswerKind.CHOICE, choice.id(), null, null), + false, + choice.label())); + } + if (q.inputConfig().allowAny()) + selections.add( + new Selection( + new ScenarioAnswer(q.id(), AnswerKind.ANY, null, null, null), false, "상관없어요")); + var counts = + candidates.countAlternatives( + e.query(), selections.stream().map(s -> evaluator.withSelection(e, q, s)).toList()); + List options = new ArrayList<>(); + for (int i = 0; i < selections.size(); i++) { + var s = selections.get(i); + var a = s.answer(); + long count = counts.get(i); + if (a.kind() == AnswerKind.NUMBER_RANGE && count == 0) continue; + options.add( + new ScenarioOption( + optionCodec.encode(context, s), + s.label(), + a.kind(), + a.min(), + a.max(), + s.minExclusive(), + count, + count > 0)); + } + return List.copyOf(options); + } + + private void numericOptions(ScenarioQuestionItem q, Evaluation e, List selections) { + var config = q.inputConfig(); + var quartiles = candidates.findQuartiles(e.query(), config.min(), config.max()); + if (quartiles.isEmpty()) return; + var cuts = new TreeSet(); + for (var value : quartiles) { + BigDecimal cut = + value + .subtract(config.min()) + .divide(config.step(), 0, RoundingMode.FLOOR) + .multiply(config.step()) + .add(config.min()); + if (cut.compareTo(config.max()) < 0) cuts.add(cut); + } + cuts.add(config.max()); + BigDecimal lower = config.min(); + boolean exclusive = false; + for (var upper : cuts) { + String unit = config.unit() == null ? "" : config.unit(); + String label = + lower.toPlainString() + + unit + + (exclusive ? " 초과 ~ " : " ~ ") + + upper.toPlainString() + + unit + + " 이하"; + selections.add( + new Selection( + new ScenarioAnswer(q.id(), AnswerKind.NUMBER_RANGE, null, lower, upper), + exclusive, + label)); + lower = upper; + exclusive = true; + } + } + private record Published( AlcoholAttributeScenario scenario, AlcoholAttributeScenarioVersion version, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java index bee7ea8cf..fe30f7309 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioEvaluationService.java @@ -8,9 +8,9 @@ import app.bottlenote.alcohols.domain.AlcoholCandidateRepository; import app.bottlenote.alcohols.domain.ScenarioAnswer; import app.bottlenote.alcohols.domain.ScenarioAnswer.ScenarioContribution; +import app.bottlenote.alcohols.domain.ScenarioAnswer.Selection; import app.bottlenote.alcohols.domain.ScenarioDefinition; import app.bottlenote.alcohols.domain.ScenarioDefinition.*; -import app.bottlenote.alcohols.domain.ScenarioSelection; import app.bottlenote.alcohols.domain.TastingTagGroup; import app.bottlenote.alcohols.domain.TastingTagGroupRepository; import app.bottlenote.alcohols.domain.TastingTagGroupTag; @@ -121,7 +121,7 @@ public Evaluation evaluate(Long groupId, ScenarioDefinition d, EvaluationInput r } AlcoholCandidateCriteria withSelection( - Evaluation e, ScenarioQuestionItem question, ScenarioSelection selection) { + Evaluation e, ScenarioQuestionItem question, Selection selection) { List contributions = new ArrayList<>(e.contributions()); var codes = e.tastingGroups().stream().map(TastingTagGroup::getCode).collect(Collectors.toSet()); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java index 6d3a9ae50..793a4c7f5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionCodec.java @@ -2,7 +2,7 @@ import static app.bottlenote.alcohols.exception.ScenarioException.invalid; -import app.bottlenote.alcohols.domain.ScenarioSelection; +import app.bottlenote.alcohols.domain.ScenarioAnswer.Selection; import app.bottlenote.global.pagination.CursorProperties; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,7 +26,7 @@ public class ScenarioOptionCodec { private final CursorProperties properties; private final ObjectMapper mapper; - public String context(Long scenarioId, Long versionId, List prefix) { + public String context(Long scenarioId, Long versionId, List prefix) { // 표기 문구와 서명 키 교체가 선행 답변의 의미를 바꾸지 않는다. var values = prefix.stream() @@ -50,7 +50,7 @@ public String context(Long scenarioId, Long versionId, List p } } - public String encode(String context, ScenarioSelection selection) { + public String encode(String context, Selection selection) { String keyId = ENCODER.encodeToString(properties.getCurrentKeyId().getBytes(StandardCharsets.UTF_8)); String body = ENCODER.encodeToString(json(new Payload(1, context, selection))); @@ -58,7 +58,7 @@ public String encode(String context, ScenarioSelection selection) { return input + "." + ENCODER.encodeToString(sign(input, properties.currentSecretBytes())); } - public ScenarioSelection decode(String token, String context, Long questionId) { + public Selection decode(String token, String context, Long questionId) { if (token == null || token.length() > 2048) throw invalid("선택지를 다시 조회해 주세요."); try { String[] parts = token.split("\\.", -1); @@ -105,5 +105,5 @@ private byte[] sign(String input, byte[] secret) { } } - private record Payload(int format, String context, ScenarioSelection selection) {} + private record Payload(int format, String context, Selection selection) {} } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java deleted file mode 100644 index 610bfd945..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/ScenarioOptionService.java +++ /dev/null @@ -1,98 +0,0 @@ -package app.bottlenote.alcohols.service; - -import app.bottlenote.alcohols.constant.ScenarioTypes.*; -import app.bottlenote.alcohols.domain.*; -import app.bottlenote.alcohols.domain.ScenarioDefinition.ScenarioQuestionItem; -import app.bottlenote.alcohols.dto.response.ScenarioEvaluationResponse.ScenarioOption; -import app.bottlenote.alcohols.service.ScenarioEvaluationService.Evaluation; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.*; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class ScenarioOptionService { - private final AlcoholCandidateRepository repository; - private final ScenarioOptionCodec codec; - private final ScenarioEvaluationService evaluator; - - @Transactional(readOnly = true) - public List options(ScenarioQuestionItem q, Evaluation e, String context) { - List selections = new ArrayList<>(); - if (q.inputKind() == Input.NUMBER_RANGE) { - numericOptions(q, e, selections); - } else { - for (var choice : q.inputConfig().choices()) - selections.add( - new ScenarioSelection( - new ScenarioAnswer(q.id(), AnswerKind.CHOICE, choice.id(), null, null), - false, - choice.label())); - } - if (q.inputConfig().allowAny()) - selections.add( - new ScenarioSelection( - new ScenarioAnswer(q.id(), AnswerKind.ANY, null, null, null), false, "상관없어요")); - var counts = - repository.countAlternatives( - e.query(), selections.stream().map(s -> evaluator.withSelection(e, q, s)).toList()); - List options = new ArrayList<>(); - for (int i = 0; i < selections.size(); i++) { - var s = selections.get(i); - var a = s.answer(); - long count = counts.get(i); - if (a.kind() == AnswerKind.NUMBER_RANGE && count == 0) continue; - options.add( - new ScenarioOption( - codec.encode(context, s), - s.label(), - a.kind(), - a.min(), - a.max(), - s.minExclusive(), - count, - count > 0)); - } - return List.copyOf(options); - } - - private void numericOptions( - ScenarioQuestionItem q, Evaluation e, List selections) { - var config = q.inputConfig(); - var quartiles = repository.findQuartiles(e.query(), config.min(), config.max()); - if (quartiles.isEmpty()) return; - var cuts = new TreeSet(); - for (var value : quartiles) { - BigDecimal cut = - value - .subtract(config.min()) - .divide(config.step(), 0, RoundingMode.FLOOR) - .multiply(config.step()) - .add(config.min()); - if (cut.compareTo(config.max()) < 0) cuts.add(cut); - } - cuts.add(config.max()); - BigDecimal lower = config.min(); - boolean exclusive = false; - for (var upper : cuts) { - String unit = config.unit() == null ? "" : config.unit(); - String label = - lower.toPlainString() - + unit - + (exclusive ? " 초과 ~ " : " ~ ") - + upper.toPlainString() - + unit - + " 이하"; - selections.add( - new ScenarioSelection( - new ScenarioAnswer(q.id(), AnswerKind.NUMBER_RANGE, null, lower, upper), - exclusive, - label)); - lower = upper; - exclusive = true; - } - } -} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java index e5723fd0e..09463d768 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/ScenarioOptionCodecTest.java @@ -4,7 +4,7 @@ import app.bottlenote.alcohols.constant.ScenarioTypes.AnswerKind; import app.bottlenote.alcohols.domain.ScenarioAnswer; -import app.bottlenote.alcohols.domain.ScenarioSelection; +import app.bottlenote.alcohols.domain.ScenarioAnswer.Selection; import app.bottlenote.alcohols.exception.ScenarioException; import app.bottlenote.global.pagination.CursorProperties; import com.fasterxml.jackson.databind.ObjectMapper; @@ -17,8 +17,8 @@ class ScenarioOptionCodecTest { private final CursorProperties keys = new CursorProperties(); private final ScenarioOptionCodec codec = new ScenarioOptionCodec(keys, new ObjectMapper()); - private final ScenarioSelection selection = - new ScenarioSelection( + private final Selection selection = + new Selection( new ScenarioAnswer( 11L, AnswerKind.NUMBER_RANGE, null, new BigDecimal("13000"), new BigDecimal("15000")), true, @@ -30,6 +30,17 @@ void setup() { keys.setCurrentSecret("unit-test-key-with-sufficient-length"); } + @Test + @DisplayName("내부 타입으로 옮긴 뒤에도 기존 형식으로 발급한 선택지를 읽는다") + void readsPreviouslyIssuedOption() { + String token = + "djE.eyJmb3JtYXQiOjEsImNvbnRleHQiOiJhMGJhNmY1NDc2ZWQyODY3MGYxZTkyOTI3N2E5MjMyNGQ0NTVhOTkzNjA0MGQ2OGZi" + + "YTk5NWIyMTM3YzZiMDRiIiwic2VsZWN0aW9uIjp7ImFuc3dlciI6eyJxdWVzdGlvbklkIjoxMSwia2luZCI6Ik5VTUJFUl9SQU5H" + + "RSIsImNob2ljZUlkIjpudWxsLCJtaW4iOjEzMDAwLCJtYXgiOjE1MDAwfSwibWluRXhjbHVzaXZlIjp0cnVlLCJsYWJlbCI6IjEz" + + "MDAw7JuQIOy0iOqzvCB-IDE1MDAw7JuQIOydtO2VmCJ9fQ.nKsE8r_SqGrtqfcb1CzBdNdxJjbnWCsCGai5viRSmGI"; + assertThat(codec.decode(token, codec.context(1L, 2L, List.of()), 11L)).isEqualTo(selection); + } + @Test @DisplayName("시간 제한 없이 저장한 선택지를 읽을 때 배타 경계와 문구를 복원한다") void preservesPredicateWithoutCursorExpiry() { @@ -77,7 +88,7 @@ void respectsKeyRotation() { @DisplayName("소수 표기만 다른 답변은 같은 조건으로 정규화한다") void normalizesDecimalScale() { var scaled = - new ScenarioSelection( + new Selection( new ScenarioAnswer( 11L, AnswerKind.NUMBER_RANGE, @@ -93,14 +104,12 @@ void normalizesDecimalScale() { @Test @DisplayName("문구만 달라지면 선행 답변 조건은 같은 문맥으로 평가한다") void contextUsesPredicatesRatherThanLabels() { - var renamed = new ScenarioSelection(selection.answer(), selection.minExclusive(), "다른 문구"); + var renamed = new Selection(selection.answer(), selection.minExclusive(), "다른 문구"); assertThat(codec.context(1L, 2L, List.of(selection))) .isEqualTo(codec.context(1L, 2L, List.of(renamed))); assertThat(codec.context(1L, 2L, List.of(selection))) .isNotEqualTo( codec.context( - 1L, - 2L, - List.of(new ScenarioSelection(selection.answer(), false, selection.label())))); + 1L, 2L, List.of(new Selection(selection.answer(), false, selection.label())))); } }