diff --git a/build.gradle.kts b/build.gradle.kts index 1ac8703..a0aafee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,10 @@ repositories { } dependencies { + // 기능성 스크리닝의 이름 해석(브랜드 한글 표기·등록명 후보·동일 제품 판정)은 Gemini를 쓴다. + // SDK 의존성 없이 RestClient로 REST를 직접 부른다 — 이 프로젝트의 다른 외부 연동 + // (MFDS, V2 product-service, 알림)과 같은 방식이고, 새 의존성이 필요 없다. + implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-validation") @@ -27,6 +31,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("org.postgresql:postgresql") + developmentOnly("org.springframework.boot:spring-boot-devtools") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") diff --git a/docs/functional-screening.md b/docs/functional-screening.md new file mode 100644 index 0000000..bef06c5 --- /dev/null +++ b/docs/functional-screening.md @@ -0,0 +1,345 @@ +# 기능성 자동 판정 (Functional Screening) + +어드민의 "기능성 확인" 탭에서 **한글 이름을 저장하면** 그 자리에서 의약품안전나라를 조회해 +후보와 기능성 유형을 채워 둔다. 어드민은 근거를 보고 고르거나, 아니면 직접 입력한다. + +목표는 "수동을 없애는 것"이 아니라 **사람이 검색하는 시간을 없애는 것**이다. 기능성은 규제 +정보라서 틀린 자동 확정이 비어 있는 큐보다 훨씬 비싸다. + +한글 이름이 트리거인 이유: 안전나라 등록명(`ITEM_NAME`)은 전부 한글이고 브랜드 한글 표기로 +시작한다("구달청귤비타씨잡티세럼"). 영문 제품명만으로는 조회가 시작조차 안 되므로, 한글 +이름이 채워지는 그 순간이 자동 조회가 가장 잘 듣는 시점이다. + +--- + +## 1. API 제약 — 실측 + +두 서비스를 쓴다. 전체 건수는 보고 195,220건 / 심사 20,629건이고, 필터가 실제로 먹는지는 +건수가 줄어드는지로 알 수 있다. + +- 보고: `https://apis.data.go.kr/1471000/FtnltCosmRptPrdlstInfoService/getRptPrdlstInq` +- 심사: `https://apis.data.go.kr/1471057/FtnltCosmSrngPrdlstInfoService04/getSrngPrdlstInq` + +| 파라미터 | 보고 | 심사 | 동작 | +|---|---|---|---| +| `item_name` | 193건 | 0건 | ✅ **부분 일치**로 걸린다 | +| `item_seq` | 0건 | 1건 | ✅ 동작(보고는 이 값이 null이라 항상 0) | +| `cosmetic_report_seq` | 1건 | — | ✅ 동작 | +| `entp_name` / `entpName` / `ENTP_NAME` | 195,220 | 20,629 | ❌ 무시 | +| `entp_seq` / `bizrno` | 195,220 | 20,629 | ❌ 무시 | +| `ee_code` / `report_flag_code` / `dept_receipt_no` | 195,220 | — | ❌ 무시 | + +동작하는 셋 중 `item_seq`·`cosmetic_report_seq`는 **조회 결과로 얻는 고유번호**라 첫 조회의 +키가 될 수 없다. 결국 **미리 알 수 있는 검색 키는 제품 이름뿐이고, 법인명으로는 조회 자체가 +불가능하다.** + +> 문서(요청변수 표)에는 `entp_name`·`bizrno`가 있지만 화장품 오퍼레이션에는 구현돼 있지 +> 않다. 표의 샘플값이 `(주)보령` / `19320001`인 것으로 보아 의약품 서비스의 표를 공유하는 +> 것으로 보인다. + +**법인명을 브랜드 후보에 붙이는 것도 소용없다.** 등록명은 "브랜드명+제품명"이라 법인 표기가 +아예 안 들어간다(19만 건 중 `(주)` 4건, `주식회사` 5건, 그나마 제품명 뒤 괄호): + +``` +토리든 1건 / 아누아 84건 / 조선미녀 51건 / 클리오 363건 +(주)토리든·토리든주식회사·주식회사토리든·"토리든 주식회사" → 네 브랜드 모두 전부 0건 +``` + +법인명은 조회가 아니라 **응답의 `ENTP_NAME`으로 검증**하는 데만 쓴다. API가 직접 알려주므로 +모델이 추측할 이유가 없다. + +--- + +## 2. 왜 제품명으로만 검색하면 안 나오는가 + +`ITEM_NAME`은 유통명이 아니라 **등록명**이고, 공백·기호가 전부 빠진 한 덩어리다. + +| 우리가 가진 이름 | 안전나라 등록명 | +|---|---| +| 구달 청귤 비타C 잡티 세럼 | `구달청귤비타씨잡티세럼` | +| 폴라초이스 10% 나이아신아마이드 부스터 | `폴라초이스10퍼센트나이아신아마이드부스터` | +| 뉴트로지나 딥클린 포밍 클렌저 | `뉴트로지나딥클린아크네포밍클렌저(보)` | +| Beauty of Joseon 릴리프 선 | `조선미녀맑은쌀선크림` | + +`비타C→비타씨`, `10%→10퍼센트`, 중간에 끼는 `아크네`, 뒤에 붙는 `(보)`. 한 글자 차이로 0건이 +된다: + +``` +'구달청귤비타C잡티세럼' → 0건 '구달청귤비타씨잡티세럼' → 2건 ✅ +'폴라초이스10%나이아신…' → 0건 '폴라초이스10퍼센트나이아신아마이드부스터' → 1건 ✅ +``` + +기존 파이썬 스크립트 결과로 실측하면 **83건 중 48건(58%)이 검색 실패**였다. + +--- + +## 3. 핵심 전략 — 브랜드 전수 조회 + +`item_name`은 부분 일치다. 브랜드 한글명만 넣으면 그 브랜드 등록 목록이 통째로 나온다. + +미매칭 12건에 실제로 돌린 결과: + +``` +[뉴트로지나] 딥클린 포밍 클렌저 brand 3건 → 0.90 뉴트로지나딥클린아크네포밍클렌저(보) ✅ +[폴라초이스] 10% 나이아신아마이드 brand 20건 → 0.92 폴라초이스10퍼센트나이아신아마이드… ✅ +[닥터디퍼런트] 311 모이스처라이저 brand 37건 → 0.94 닥터디퍼런트131모이스처라이저 ❌ 다른 제품! +[아누아] 어성초 77 수딩 토너 brand 84건 → 0.64 아누아어성초70수딩콜라겐마스크 ❌ 다른 제품 +[디 오디너리] 글리코릭 애시드 토너 brand 0건 → 브랜드 자체가 없음 ⇒ 음성 근거 +``` + +세 가지를 동시에 준다. + +1. **회수율** — 직접 검색이 실패한 제품이 브랜드 목록 안에서 발견된다. +2. **유사도만 믿으면 안 된다** — `311 vs 131`(0.94), `77 vs 70`처럼 숫자 한 자리가 다른 + 제품이 높은 점수를 받는다. 규칙으로 막아야 한다. +3. **"브랜드 0건"은 강한 음성 근거** — 단, 한글 표기로 조회했을 때만이다. + +> ⚠️ 화면의 "브랜드 등록 N건"은 엄밀히는 *`item_name=브랜드명`으로 부분 일치한 건수*다. +> 브랜드명이 등록명에 안 들어간 제품은 빠지고, 다른 제품명 중간에 우연히 들어가면 섞인다 +> (`어누아` → `이시오에프리미엄리페**어누아**비비크림`). 법인 기준 등록 수와도 다르다. + +--- + +## 4. LLM이 하는 일 — 이름 문제 셋 + +`infrastructure/GeminiProductNameResolver.java` (포트: `application/port/ProductNameResolverPort`) + +모델은 **Gemini**(`gemini-3.8-flash`). Interactions API(`POST /v1beta/interactions`)를 +`RestClient`로 직접 부른다 — 이 프로젝트의 다른 외부 연동과 같은 방식이라 SDK 의존성이 없다. + +| | 메서드 | 입력 → 출력 | 호출 | +|---|---|---|---| +| ① | `koreanBrandAliases` | `Torriden` → `["토리든", …]` 최대 4개 | 브랜드당 1회(캐시) | +| ② | `registrationNameCandidates` | `구달 청귤 비타C 잡티 세럼` → 등록명 후보 최대 6개 | 제품당 1회 | +| ③ | `judge` | 제품 + 등록 후보 20건 → `{index, confidence, reason}` | 조건부 1회 | + +**LLM은 기능성을 판단하지 않는다.** 유형은 오직 안전나라 응답에서만 나온다(6절). 프롬프트 +어디에도 "이게 미백인가"를 묻는 곳이 없다 — 규제 정보를 모델이 지어내지 못하게 하는 경계다. + +`judge`는 **하나만 지목**한다. 순위나 복수 추천이 아니라 "몇 번이 같은 제품인가, 없으면 -1". + +### ① 브랜드 한글 표기 + +실제로 갈리는 축으로 만들게 한다. 법인 표기는 넣지 말라고 명시한다(1절 참조). + +``` +음차 그대로 Goodal → 구달, d'Alba → 달바, Torriden → 토리든 +국내 유통명 Beauty of Joseon → 조선미녀 (뷰티오브조선 0건) +음차 갈림 numbuzin → 넘버즈인 / 넘버스인 +숫자·영문 혼합 SKIN1004 → 스킨1004 / 스킨천사 +``` + +### ② 등록명 후보 + +규칙 2개 + LLM 최대 6개, 합쳐서 최대 8개(`max-search-terms`)로 각각 조회한다. + +- 규칙: `브랜드한글명+제품명` 정규화 / 제품명만 정규화 +- LLM: 표기 변환(`비타C→비타씨`), 대괄호·용량 제거, 갈리는 표기는 각각, **제품명이 길면 + 앞부분만 자른 형태도 하나** — 부분 일치라 `구달청귤비타`만으로도 걸린다 +- 제품명의 숫자는 절대 바꾸지 않게 한다 + +### ③ 후보 판정 + +**임계값을 통과한 것만 넘기지 않는다.** 유사도는 표기가 크게 다른 제품을 통째로 떨어뜨린다 — +`Beauty of Joseon 릴리프 선`은 정답인 `조선미녀맑은쌀선크림`과 **0.36으로 꼴찌**였다. +브랜드 등록 목록을 점수순으로 넉넉히(기본 20건) 넘기고 고르는 일을 판정에 맡긴다. + +--- + +## 5. 한글 이름 저장 → 판정까지 + +`POST /admin/products/{id}/workflow/functional-screening` → `FunctionalScreeningService.decide()` + +``` +0. 한글 이름 저장 (비어 있으면 여기서 중단 — 조회할 근거가 없다) + ↓ +1. ①LLM 브랜드 한글 표기 (+ 원본 브랜드명) + ↓ +2. 별칭마다 브랜드 전수 조회 → 전부 합침 + · 첫 성공에서 멈추지 않는다. 표기가 갈리는 브랜드가 있다. + · 가장 많이 걸린 표기를 이름 비교 기준(brandKo)으로 + · dominantEntpName = 응답에서 가장 많은 업체명 (후보 검증 기준값) + ↓ +3. ②LLM 등록명 후보 + 규칙 2개 → 최대 8개 검색어로 추가 조회 + ↓ +4. score() — 취하 건 제외, 중복 합치기, 점수순 정렬. 여기서는 거르지 않는다 + · score : LCS 기반 대칭 유사도 + · coverage : 우리 이름이 등록명에 담긴 비율(우리 쪽 길이로만 나눔) + ↓ +5. strong = score ≥ 0.60 또는 coverage ≥ 0.95 + strong 최상위가 confirmable() → AUTO_CONFIRMED (③ 호출 안 함) + ↓ +6. ③LLM 판정 — 상위 20건을 넘겨 하나 지목 + ↓ +7. 결말 판정 → functional_screenings 저장 → 화면 +``` + +### 결말 + +| 조건 | 결과 | +|---|---| +| 점수 ≥ `auto-threshold` + 숫자·업체·유형 규칙 통과 | **AUTO_CONFIRMED** | +| ③이 지목함 | **NEEDS_REVIEW** — 고른 것을 맨 앞에, 유형까지 채워 둠 | +| ③이 "없다" + strong 있었음 | **NEEDS_REVIEW** | +| ③이 "없다" + strong 없음 | **NOT_MATCHED** — 본 후보들을 근거로 남김 | +| 후보 0건 | **NOT_MATCHED**(또는 조건 만족 시 AUTO_NONE) | +| API·LLM 오류 | **FAILED** — "기능성 아님"으로 접지 않는다 | + +**③이 지목해도 자동 확정하지 않는다.** 이름이 거의 같은 경우(5단계)를 빼면 확정은 사람 +몫이다 — 모델의 근거가 아무리 그럴듯해도 등록명이 다르다는 건 다른 제품일 수 있다는 뜻이고, +기능성은 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 것보다 크다. + +### 자동 확정을 막는 규칙 + +- **숫자 토큰 불일치** — `311` ≠ `131`, `77` ≠ `70` (실측에서 나온 하드 룰) +- **부분 이름** — `달바 워터풀`은 선크림·선쿠션·커버베이지 21/22/23호가 모두 coverage 1.0. + 고를 근거가 없으므로 확정하지 않고 전부 보여 준다 +- **업체 불일치** — `ENTP_NAME`이 브랜드 등록 업체와 다르고 등록명이 브랜드로 시작하지도 않음 +- **유형 도출 실패** — 등록은 찾았지만 효능효과도 SPF/PA도 없음 + +### AUTO_NONE ("기능성 아님" 자동 확정) — 기본 꺼짐 + +켤 경우에도 아래를 모두 만족할 때만: + +- 브랜드 전수 조회를 **한글 표기로** 했고 등록이 **0건** + (영문 브랜드명으로는 무조건 0건이라 근거가 되지 못한다) +- 카테고리가 `sunscreens`가 아님 — 선크림은 법적으로 기능성이어야 하므로 미검색은 실패로 본다 +- 제품명에 `선/자차/톤업/미백/주름/브라이트닝/UV/SPF` 같은 기능성 암시 토큰이 없음 + +--- + +## 6. 기능성 유형은 어디서 나오는가 + +`domain/FunctionalClaims.java`가 **유일한 출처**다. + +`COSMETIC_TARGET_FLAG_NAME`(제10조 제1항 제○호)은 효능이 아니라 **보고 근거 구분**이다. +1호 안에 미백·주름·염모·탈색이 전부 섞여 있어 유형 매핑에 쓸 수 없다: + +``` +('1','제10조 제1항 제1호', EE 3, '피부의 미백에 도움을 준다. 피부의 주름개선에 도움을 준다.') 402 +('1','제10조 제1항 제1호', EE null, null) 355 +('2','제10조 제1항 제2호', EE null, null, SPF 있음) 153 +('1','제10조 제1항 제1호', EE 5, '모발의 염모') 117 +``` + +그래서 이 순서로 도출한다. + +1. `EE_NAME` 텍스트 → 미백 / 주름개선 / 여드름 / 피부장벽 / 튼살 / 자외선 +2. `EE_NAME`이 비고 `SPF`·`PA`가 있으면 → **자외선 차단** (선크림 보고 건의 전형) +3. `EE_NAME`이 `염모`·`탈색`·`제모`면 → 우리 6분류 밖 ⇒ **자동 확정하지 않고 사람에게** +4. 둘 다 없으면 → **도출 불가 ⇒ 사람에게** + +`CANCEL_APPROVAL_YN='Y'`(취하)인 행은 후보에서 제외한다. + +심사 API에는 효능효과가 없어 유형을 도출할 수 없다 — 심사에서만 발견되면 "기능성이긴 하다" +까지만 알 수 있고 유형은 사람이 고른다. + +--- + +## 7. 데이터 · 상태 + +`ProductStatus`는 건드리지 않는다. status는 "누가 움직일 차례인가"만 말하고, 판정의 내막은 +`functional_screenings`(제품당 1건)가 담는다. + +``` +functional_screenings + _id = product_id + outcome AUTO_CONFIRMED | AUTO_NONE | NEEDS_REVIEW | NOT_MATCHED | FAILED + claims [WHITENING, ...] ← 안전나라에서 도출된 유형 + candidates[] { item_name, entp_name, source, score, coverage, claims, + report_date, ee_name, spf, pa, numeric_match, brand_match } + selected_index 판정이 고른 후보(없으면 -1) + confidence, reason 판정 근거 (LLM 사유 포함) + brand_registry_count 브랜드 조회 건수 (0이면 음성 근거) + decided_by AUTO | ADMIN + engine_version 판정 규칙 버전 — 규칙을 바꾸면 재판정 대상을 고를 수 있다 + screened_at +``` + +제품의 한글 이름은 Mongo `products.name_kr` 이다(예전 `name_ko`에서 변경). 같은 컬렉션에 +쓰는 seoulection-server-V2 `product-admin/AdminProductDocument`도 함께 바꿔야 하고, 기존 +문서가 있으면 배포 전에 `$rename`을 돌려야 한다. + +--- + +## 8. 화면 + +기능성 확인 탭은 위에서 아래로 흐른다. + +1. **한글 이름 + [저장하고 자동 조회]** — 자동화의 입구 +2. **자동 조회 결과 카드** + - 판정 배지와 사유 한 줄 + - 후보 표: 등록명 · 업체 · 등록일 · 효능효과 · 도출 유형 · 점수, 행마다 [이 후보로 채우기] + - 판정이 고른 행에는 **`AI 지목` 배지 + 행 강조** + - "브랜드 등록 N건" +3. **직접 입력 폼** — 자동이 못 한 것만 사람이 채운다 + +**라디오는 미리 선택하지 않는다.** 정확히는 유형이 나온 경우만 `기능성 확인`을 찍어 두고, +**"기능성 아님"은 절대 미리 고르지 않는다** — 확인 없이 저장만 눌러도 식약처 기능성이 아니라는 +사실이 기록되는 게 이 폼에서 가장 비싼 실수이고, "자동이 못 찾은 것"과 "실제로 기능성이 아닌 +것"은 겉보기가 같다. + +외부 호출 중에는 버튼을 잠그고 스피너·진행 띠를 띄운다(`data-busy`). 잠그는 게 핵심이다 — +응답이 늦으면 어드민이 한 번 더 누르고, 서버는 같은 외부 API를 두 번 부른다. + +--- + +## 9. 비용 + +최악의 경우 한 번에 **안전나라 ~36회 + Gemini 3회**다(별칭 4 × 5페이지 + 검색어 8 × 2). +실측 **18~22초**. 로딩 표시가 필요한 이유이자, 큐를 한 번에 훑을 때 상한을 두는 이유다. + +--- + +## 10. 설정 + +```yaml +admin: + functional-screening: + enabled: true + apply-decisions: false # 자동 확정이 상태까지 옮길지. 기본 꺼짐 — 확정은 어드민이 + auto-threshold: 0.95 # 이 점수 이상이면 판정 없이도 확정 후보 + candidate-threshold: 0.60 # 이 미만은 strong 으로 보지 않는다 + coverage-threshold: 0.95 # 이름을 일부만 적은 경우를 건지는 값 + max-candidates: 8 # 화면에 남길 후보 수 + judge-pool-size: 20 # 판정에 넘길 등록 건 수 + max-brand-aliases: 4 # 브랜드 한글 표기 후보 수(전부 조회해 합침) + max-search-terms: 8 # 등록명 검색어 상한 + auto-conclude-none: false # "기능성 아님"까지 자동 확정할지 + mfds: + service-key: ${MFDS_SERVICE_KEY:} # data.go.kr 인코딩 키 그대로. 심사·보고 공용 + page-size: 500 + max-brand-pages: 4 + llm: + enabled: ${FUNCTIONAL_SCREENING_LLM:false} + model: ${GEMINI_MODEL:gemini-3.8-flash} + api-key: ${GEMINI_API_KEY:} +``` + +키는 `seoulection-server-V2/.env`에 넣고 compose가 어드민 컨테이너에 환경변수로 꽂아 준다 +(어드민 저장소에는 `.env`가 없다). + +`llm.enabled=false`면 `HeuristicProductNameResolver`가 대신 뜬다 — 규칙 기반 표기 변환만 하고 +**판정은 하지 않아** 애매한 건 전부 사람 큐로 간다. 즉 **LLM 없이도 동작하고**, 켜면 회수율이 +올라간다. + +### 함정 두 가지 + +- 서비스 키는 이미 URL 인코딩된 상태로 발급된다. `RestClient.uri(String)`에 넣으면 템플릿으로 + 보고 한 번 더 인코딩해 `%2F`가 `%252F`가 되고 403 "등록되지 않은 서비스키"가 난다. + `URI.create()`로 넘겨야 한다. +- 키가 비면 조회가 **빈 목록이 아니라 예외**를 던진다. 조용히 0건이 되면 "기능성 아님"으로 + 기록될 수 있기 때문이다. + +--- + +## 11. 남은 것 + +1. **AUTO_NONE 켜기** — 브랜드 0건 케이스의 정확도를 실제 데이터로 확인한 뒤에만 +2. **apply-decisions 켜기** — 자동 확정과 사람 판단의 일치율을 본 뒤에 +3. **밀린 제품 일괄 조회** — `POST /admin/products/functional-screening`이 큐를 훑는다 + (기본 50건). 목록 화면에 버튼은 아직 없다 +4. **재판정** — 규칙을 고치면 `engine_version`이 다른 판정을 골라 다시 돌리는 일이 필요해진다 +5. **전수 적재 검토** — 보고 391요청 + 심사 42요청 ≈ 4분이면 19만 건을 로컬에 둘 수 있다. + 그러면 업체명 조회가 가능해지고, 자동 조회가 18~22초에서 수십 ms로 줄고, 쿼터 걱정이 + 사라진다. `MfdsCatalogPort` 구현만 갈아끼우면 된다 diff --git a/src/main/java/com/seoulection/admin/common/AdminHttpClientConfig.java b/src/main/java/com/seoulection/admin/common/AdminHttpClientConfig.java new file mode 100644 index 0000000..f6ab7ea --- /dev/null +++ b/src/main/java/com/seoulection/admin/common/AdminHttpClientConfig.java @@ -0,0 +1,27 @@ +package com.seoulection.admin.common; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * 서비스 호출용 HTTP 클라이언트 기본값. + * + *

타임아웃을 어댑터가 아니라 여기서 거는 이유: 어댑터가 {@code requestFactory()} 를 부르면 + * 테스트가 붙여 둔 {@code MockRestServiceServer} 의 팩토리를 덮어써서, 흉내 내려던 요청이 + * 진짜로 나가 버린다(2026-09-08 실측). 설정은 설정에 두고 어댑터는 baseUrl 만 정한다. + * + *

🔴 타임아웃을 빼지 말 것. 기본값은 무한 대기라, 호출 대상이 멈추면 어드민 화면도 함께 멈춘다. + */ +@Configuration +public class AdminHttpClientConfig { + + @Bean + public RestClient.Builder serviceRestClientBuilder() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + return RestClient.builder().requestFactory(factory); + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java index 4b473bb..ce34d56 100644 --- a/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java @@ -14,7 +14,10 @@ public class IngredientSeedConfiguration { @Bean ApplicationRunner seedIngredients(IngredientPostgresRepository repository) { return args -> { - if (!repository.findAll().isEmpty()) return; + // ⚠️ "성분이 하나라도 있으면 통째로 건너뛴다"로 두지 말 것. 그러면 아래 목록에 + // 성분을 추가해도 영영 들어가지 않는다 — 코드에는 있는데 DB 에는 없어서 + // "왜 없지"를 찾는 데 시간을 쓴다. 대신 성분마다 없을 때만 넣는다. + // 이미 있는 것은 건드리지 않으므로 어드민이 화면에서 고친 값도 살아남는다. List.of( i("00000000-0000-0000-0000-000000000101","Hyaluronic Acid","Hyaluronic Acid","히알루론산","HYALURONAN",List.of("HA","히알루론산"),List.of("HYALURONIC_ACID_SEARCH"),Map.of("WATER_SCORE","CORE","WRINKLE_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), i("00000000-0000-0000-0000-000000000102","Sodium DNA","Sodium DNA","PDRN","NUCLEOTIDE_DERIVATIVE",List.of("PDRN","소듐 DNA"),List.of("PDRN_SEARCH"),Map.of("WATER_SCORE","SUPPORT","WRINKLE_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), @@ -33,7 +36,7 @@ ApplicationRunner seedIngredients(IngredientPostgresRepository repository) { i("00000000-0000-0000-0000-000000000115","Human Oligopeptide-1","Human Oligopeptide-1","EGF","PEPTIDE_GROWTH_FACTOR",List.of("EGF","상피세포성장인자"),List.of("EGF_SEARCH"),Map.of("ROUGH_SCORE","SUPPORT","WRINKLE_SCORE","SUPPORT"),Map.of("STABILITY","PROTEIN_STABILITY_SENSITIVE")) ,i("00000000-0000-0000-0000-000000000117","Sodium Hyaluronate","Sodium Hyaluronate","히알루론산 나트륨","HYALURONAN",List.of("히알루론산 나트륨"),List.of(),Map.of("WATER_SCORE","CORE","WRINKLE_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")) ,i("00000000-0000-0000-0000-000000000118","Hydrolyzed Hyaluronic Acid","Hydrolyzed Hyaluronic Acid","가수분해 히알루론산","HYALURONAN",List.of("Hydrolyzed HA","가수분해 히알루론산"),List.of(),Map.of("WATER_SCORE","CORE","WRINKLE_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")) - ).forEach(seed -> repository.save(seed.getId(), seed.getInciName(), seed.getDisplayNameKo(), + ).forEach(seed -> repository.saveIfAbsent(seed.getId(), seed.getInciName(), seed.getDisplayNameKo(), seed.getFamily(), seed.getAliases(), seed.getEffects(), seed.getProperties())); }; } diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientPostgresRepository.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientPostgresRepository.java index dd5b2d7..7b7003f 100644 --- a/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientPostgresRepository.java +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientPostgresRepository.java @@ -49,6 +49,30 @@ public void deletePropertyDefinition(String key) { jdbc.update("delete from property_definition where property_key = ?", key); } + /** + * 시드 전용 — 없을 때만 넣고, 이미 있으면 아무것도 하지 않는다. + * + *

{@link #save} 는 upsert 라 어드민이 화면에서 고친 값을 재기동이 되돌린다. + * 시드의 목적은 "기본 성분을 깔아 두는 것"이지 "코드 값을 강제하는 것"이 아니다. + * + *

🔴 id 와 inci_name 을 둘 다 본다. id 로만 보면, 어드민이 같은 성분을 다른 id 로 + * 만들어 둔 경우 삽입이 {@code uq_ingredient_inci_name}(대소문자 무시) 위반으로 터지고 + * 앱이 기동하지 않는다 — 시드는 ApplicationRunner 라 실패가 곧 기동 실패다. + * + * @return 실제로 넣었으면 true + */ + @Transactional + public boolean saveIfAbsent(String id, String inci, String ko, String family, + List aliases, Map effects, + Map properties) { + Long existing = jdbc.queryForObject( + "select count(*) from ingredient where id = ? or lower(inci_name) = lower(?)", + Long.class, id, inci); + if (existing != null && existing > 0) return false; + save(id, inci, ko, family, aliases, effects, properties); + return true; + } + @Transactional public void save(String id, String inci, String ko, String family, List aliases, Map effects, diff --git a/src/main/java/com/seoulection/admin/notification/application/NotificationDevicePort.java b/src/main/java/com/seoulection/admin/notification/application/NotificationDevicePort.java new file mode 100644 index 0000000..1c43181 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/application/NotificationDevicePort.java @@ -0,0 +1,20 @@ +package com.seoulection.admin.notification.application; + +import java.util.List; + +/** + * 기기 등록 접근 포트. + * + *

{@code ProductIngredientPort} 와 같은 이유로 인터페이스다 — 지금은 notification-service 의 + * 어드민 API 를 부르지만, 저장소가 옮겨 가거나 서비스가 분리돼도 화면은 이 계약만 본다. + */ +public interface NotificationDevicePort { + + List activeOf(Long userId); + + /** 지우지 않고 해제한다 — 서비스 쪽이 revoked_at 을 찍는다. */ + void revoke(Long registrationId); + + /** 토큰 전문은 받지 않는다. 화면은 기기를 구분하기만 하면 된다. */ + record DeviceView(Long id, String platform, String tokenPreview) {} +} diff --git a/src/main/java/com/seoulection/admin/notification/application/NotificationDeviceService.java b/src/main/java/com/seoulection/admin/notification/application/NotificationDeviceService.java new file mode 100644 index 0000000..3262af9 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/application/NotificationDeviceService.java @@ -0,0 +1,36 @@ +package com.seoulection.admin.notification.application; + +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 기기 등록 유스케이스. + * + *

지금은 포트로 넘기기만 한다. 그래도 두는 이유는 규칙을 놓을 자리가 필요해서다 — + * 사용자 ID 검증, "이미 해제된 기기" 판단, 해제 사유 기록 같은 것이 생기면 화면도 어댑터도 + * 아닌 여기가 그 자리다. {@code ProductService}·{@code SurveyService} 와 같은 층이다. + * + *

컨트롤러가 포트를 직접 잡으면 그 규칙이 화면으로 새거나 어댑터마다 중복된다. + */ +@Service +public class NotificationDeviceService { + + private final NotificationDevicePort devicePort; + + public NotificationDeviceService(NotificationDevicePort devicePort) { + this.devicePort = devicePort; + } + + /** 사용자 ID 가 없으면 조회하지 않는다 — 전체 기기 목록은 어드민이 볼 이유가 없고 양도 많다. */ + public List activeDevices(Long userId) { + return userId == null ? List.of() : devicePort.activeOf(userId); + } + + public void revoke(Long registrationId) { + if (registrationId == null) { + throw new IllegalArgumentException("해제할 기기를 지정해 주세요."); + } + devicePort.revoke(registrationId); + } +} diff --git a/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java b/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java new file mode 100644 index 0000000..1ec0216 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java @@ -0,0 +1,47 @@ +package com.seoulection.admin.notification.infrastructure; + +import com.seoulection.admin.notification.application.NotificationDevicePort; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; + +/** + * notification-service 의 어드민 API 를 부른다. + * + *

🔴 서비스 키가 비어 있으면 403 이 나는데, 그 실패는 기동이 아니라 화면을 눌렀을 때 + * 드러난다. {@code admin.notification-service.service-key} 를 반드시 함께 넣을 것. + */ +@Component +public class ApiNotificationDeviceAdapter implements NotificationDevicePort { + + private final RestClient client; + private final String serviceKey; + + public ApiNotificationDeviceAdapter( + RestClient.Builder builder, + @Value("${admin.notification-service.base-url:http://notification-service:8080}") String baseUrl, + @Value("${admin.notification-service.service-key:}") String serviceKey) { + this.serviceKey = serviceKey; + this.client = builder.baseUrl(baseUrl).build(); + } + + @Override + public List activeOf(Long userId) { + List devices = client.get() + .uri(uriBuilder -> uriBuilder.path("/internal/admin/v1/notifications/devices") + .queryParam("userId", userId).build()) + .header("X-Service-Key", serviceKey) + .retrieve().body(new ParameterizedTypeReference>() {}); + return devices == null ? List.of() : devices; + } + + @Override + public void revoke(Long registrationId) { + client.delete().uri("/internal/admin/v1/notifications/devices/{id}", registrationId) + .header("X-Service-Key", serviceKey) + .retrieve().toBodilessEntity(); + } +} diff --git a/src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java b/src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java new file mode 100644 index 0000000..8c614f4 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java @@ -0,0 +1,20 @@ +package com.seoulection.admin.notification.presentation; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * 수동 푸시 발송 화면의 진입점. + * + *

아직 notification-service와 연결하지 않았으므로 이 컨트롤러는 데이터를 만들거나 발송하지 않는다. + * 다음 단계에서 이 패키지에 notification-service HTTP 클라이언트를 붙이면, 템플릿의 대상 수와 이력 + * 영역만 실제 응답으로 교체하면 된다.

+ */ +@Controller +public class NotificationAdminController { + + @GetMapping("/admin/notifications") + public String page() { + return "notifications"; + } +} diff --git a/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java b/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java new file mode 100644 index 0000000..945091c --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java @@ -0,0 +1,47 @@ +package com.seoulection.admin.notification.presentation; + +import com.seoulection.admin.notification.application.NotificationDeviceService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + + +/** + * 기기 토큰 관리 화면. + * + *

문의 대응용이다 — "푸시가 안 온다", "지운 기기로 계속 온다" 는 죽은 토큰이 원인인 경우가 + * 많은데, 그것을 확인하고 끊을 자리가 없었다. + * + *

사용자 ID 로만 조회한다. 토큰으로 역조회하지 않는 이유: 어드민이 토큰을 알 방법이 없고, + * 토큰을 입력받는 화면을 두면 그 값이 로그·브라우저 기록에 남는다. + */ +@Controller +public class NotificationDeviceController { + + private final NotificationDeviceService service; + + public NotificationDeviceController(NotificationDeviceService service) { + this.service = service; + } + + @GetMapping("/admin/notifications/devices") + public String page(@RequestParam(required = false) Long userId, Model model) { + model.addAttribute("userId", userId); + model.addAttribute("devices", service.activeDevices(userId)); + model.addAttribute("searched", userId != null); + return "notification-devices"; + } + + @PostMapping("/admin/notifications/devices/{registrationId}/revoke") + public String revoke(@PathVariable Long registrationId, @RequestParam Long userId, + RedirectAttributes redirectAttributes) { + service.revoke(registrationId); + redirectAttributes.addFlashAttribute("successMessage", + "기기 등록을 해제했습니다. 이 기기로는 더 이상 발송되지 않습니다."); + return "redirect:/admin/notifications/devices?userId=" + userId; + } +} diff --git a/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java b/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java new file mode 100644 index 0000000..09e9b6e --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java @@ -0,0 +1,52 @@ +package com.seoulection.admin.product.application.dto; + +import java.math.BigDecimal; + +/** + * 제품 안에서의 성분 특성 한 줄. + * + *

왜 컬럼이 아니라 이런 목록인가: 담아야 할 축이 순도만이 아니다. "이 제품의 히알루론산은 + * 저분자", "아줄렌 순도 99%" 처럼 성분·제품마다 다른 축이 계속 생긴다. 컬럼으로 두면 축이 + * 하나 늘 때마다 마이그레이션이 필요하다. + * + *

수치는 {@code valueMin}/{@code valueMax}, 범주형은 {@code valueText} 를 쓴다. + * 어느 쪽을 쓸지는 {@code property_definition.value_type} 이 정한다. + */ +public record ProductIngredientProperty(String propertyKey, String displayNameKo, String valueType, + String valueText, BigDecimal valueMin, BigDecimal valueMax, + String valueUnit, String notes) { + + /** 폼에서 올라온 값. 표시용 이름과 타입은 정의에서 오는 것이라 여기서는 모른다. */ + public ProductIngredientProperty(String propertyKey, String valueText, BigDecimal valueMin, + BigDecimal valueMax, String valueUnit, String notes) { + this(propertyKey, null, null, valueText, valueMin, valueMax, valueUnit, notes); + } + + /** + * 숫자형인가. 화면이 최소·최대 두 칸을 그릴지 텍스트 한 칸을 그릴지 이걸로 정한다. + * + *

저장된 특성에도 타입이 붙어 있어야 하는 이유: 화면에서 정의 목록을 다시 뒤져 + * 짝을 찾는 식은 표현식이 길어지고 실제로 한 번 깨진 적이 있다. 조회 쿼리가 정의를 + * 이미 조인하고 있으니 거기서 같이 가져온다. + */ + public boolean numeric() { + if (valueType == null) { + return valueMin != null || valueMax != null; + } + String upper = valueType.toUpperCase(); + return upper.startsWith("NUMERIC") || upper.startsWith("NUMBER"); + } + + /** 화면에 한 줄로 보여줄 값. 범위면 "1~5 %", 단일값이면 "99 %", 범주형이면 그 문자열. */ + public String display() { + if (valueText != null && !valueText.isBlank()) return valueText; + if (valueMin == null && valueMax == null) return ""; + String unit = valueUnit == null ? "" : " " + valueUnit; + if (valueMin != null && valueMax != null && valueMin.compareTo(valueMax) != 0) { + return valueMin.stripTrailingZeros().toPlainString() + "~" + + valueMax.stripTrailingZeros().toPlainString() + unit; + } + BigDecimal single = valueMin != null ? valueMin : valueMax; + return single.stripTrailingZeros().toPlainString() + unit; + } +} diff --git a/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientResult.java b/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientResult.java new file mode 100644 index 0000000..10cbfce --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientResult.java @@ -0,0 +1,29 @@ +package com.seoulection.admin.product.application.dto; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 화면에 뿌릴 제품 성분 한 줄. + * + *

{@code matchedInciName} 이 null 이면 성분 사전에서 못 찾은 것이다 — 화면이 그걸 표시해 + * 어드민이 사전에 추가하도록 유도한다. 못 찾는 것은 오류가 아니라 정상적인 상태다. + * + *

순도가 별도 필드가 아니라 {@code properties} 안에 있는 이유는 {@link ProductIngredientProperty} 참고. + */ +public record ProductIngredientResult(long id, String ingredientId, String rawName, int order, + BigDecimal concentrationMin, BigDecimal concentrationMax, String unit, + String notes, String matchedInciName, String matchedNameKo, + List properties) { + + /** 성분 사전과 연결됐는가. 화면의 매칭 배지가 이걸 본다. */ + public boolean matched() { + return ingredientId != null && !ingredientId.isBlank(); + } + + /** 표시용 이름 — 사전에 있으면 한글명, 없으면 원문. */ + public String displayName() { + if (matchedNameKo != null && !matchedNameKo.isBlank()) return matchedNameKo; + return rawName; + } +} diff --git a/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java b/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java index 2fa654b..a070ecd 100644 --- a/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java @@ -13,6 +13,7 @@ public record ProductResult( String id, String asin, String name, + String nameKo, String brand, String category, String description, @@ -35,6 +36,7 @@ public static ProductResult from(Product product) { product.id(), product.asin(), product.name(), + product.nameKo(), product.brand(), product.category(), product.description(), diff --git a/src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java b/src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java new file mode 100644 index 0000000..b0d9d1c --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java @@ -0,0 +1,22 @@ +package com.seoulection.admin.product.application.dto; + +/** + * 특성 키의 정의. 화면이 "무엇을 입력할 수 있는가"를 이 목록으로 그린다. + * + *

{@code valueType} 이 숫자형이면 최소·최대 두 칸을, 그 외에는 텍스트 한 칸을 보여준다. + * + *

⚠️ 실제 {@code property_definition.value_type} 값은 {@code NUMBER}/{@code TEXT} 다. + * 예전 판정은 {@code NUMERIC} 으로 시작하는지만 봐서 모든 정의가 텍스트로 렌더링됐다 + * (순도·분자량에 최소/최대 대신 자유 입력 칸이 떴다). 두 어휘를 모두 받는다. + */ +public record PropertyDefinitionResult(String propertyKey, String displayNameKo, String valueType, + String valueUnit, String description) { + + public boolean numeric() { + if (valueType == null) { + return false; + } + String upper = valueType.toUpperCase(); + return upper.startsWith("NUMERIC") || upper.startsWith("NUMBER"); + } +} diff --git a/src/main/java/com/seoulection/admin/product/application/port/ProductIngredientPort.java b/src/main/java/com/seoulection/admin/product/application/port/ProductIngredientPort.java new file mode 100644 index 0000000..e261a3b --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/port/ProductIngredientPort.java @@ -0,0 +1,44 @@ +package com.seoulection.admin.product.application.port; + +import com.seoulection.admin.product.application.dto.ProductIngredientProperty; +import com.seoulection.admin.product.application.dto.ProductIngredientResult; +import com.seoulection.admin.product.application.dto.PropertyDefinitionResult; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 제품-성분 데이터에 접근하는 포트. + * + *

왜 인터페이스인가: 지금은 어드민이 PostgreSQL 에 직접 쓴다(V1 구조). 앞으로 이 데이터의 + * 주인은 seoulection-server-V2 의 product-service 가 되고, 어드민은 그 서비스의 어드민 API 를 + * 부르게 된다. 화면과 서비스가 이 포트만 보게 해 두면 전환이 설정 한 줄이 된다: + * + *

+ *   admin.product-ingredient.source = jdbc   기본값 — PostgreSQL 직접
+ *   admin.product-ingredient.source = api    V2 product-service 호출
+ * 
+ * + *

🔴 두 구현이 같은 결과를 내는지는 컴파일러가 봐 주지 않는다. 전환 전에 두 어댑터를 같은 + * 시나리오로 돌려 비교할 것 — 설정이 틀려도 앱은 정상 기동하고, 화면을 눌러야 드러난다. + */ +public interface ProductIngredientPort { + + /** 전성분을 통째로 교체한다. 원문을 성분 사전과 대조해 찾으면 연결하고 못 찾으면 비워 둔다. */ + void replace(String productId, List rawNames, String source); + + List findByProductId(String productId); + + /** + * 성분 한 행의 보완 입력. + * + *

{@code properties} 는 통째 교체다. 부분 갱신으로 두면 "특성을 지웠다"를 표현할 + * 방법이 없다(빈 목록과 미지정을 구분해야 한다). {@code null} 이면 특성은 건드리지 않는다. + */ + void review(String productId, long rowId, String ingredientId, BigDecimal concentrationMin, + BigDecimal concentrationMax, String unit, String notes, + List properties); + + /** 입력 가능한 특성 목록. 화면이 이걸로 입력 칸을 그린다. */ + List propertyDefinitions(); +} diff --git a/src/main/java/com/seoulection/admin/product/application/service/ProductService.java b/src/main/java/com/seoulection/admin/product/application/service/ProductService.java index a869496..c61a083 100644 --- a/src/main/java/com/seoulection/admin/product/application/service/ProductService.java +++ b/src/main/java/com/seoulection/admin/product/application/service/ProductService.java @@ -14,6 +14,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.math.BigDecimal; +import com.seoulection.admin.product.application.dto.ProductIngredientResult; @Service public class ProductService { @@ -22,7 +24,7 @@ public class ProductService { private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "_id"); private final ProductRepository repository; - private final com.seoulection.admin.product.infrastructure.repository.ProductIngredientPostgresRepository productIngredientRepository; + private final com.seoulection.admin.product.application.port.ProductIngredientPort productIngredientRepository; public ProductService(ProductRepository repository) { this.repository = repository; @@ -31,7 +33,7 @@ public ProductService(ProductRepository repository) { @Autowired public ProductService(ProductRepository repository, - com.seoulection.admin.product.infrastructure.repository.ProductIngredientPostgresRepository productIngredientRepository) { + com.seoulection.admin.product.application.port.ProductIngredientPort productIngredientRepository) { this.repository = repository; this.productIngredientRepository = productIngredientRepository; } @@ -41,7 +43,19 @@ public ProductResult register(String name, String brand, String category) { } public ProductResult register(String name, String brand, String category, List ingredients) { - ProductResult result = ProductResult.from(repository.insert(Product.pending(name, brand, category, ingredients))); + return register(name, null, brand, category, ingredients); + } + + /** + * 신규 등록 — 한글 이름과 전성분까지 함께 받는다. + * + *

함량은 여기서 받지 않는다. 성분 행이 저장돼야 각 행에 붙일 수 있기 때문이다 — + * 등록 직후 성분 보완 탭으로 보내 그 자리에서 채우게 한다. + */ + public ProductResult register(String name, String nameKo, String brand, String category, + List ingredients) { + ProductResult result = ProductResult.from( + repository.insert(Product.pending(name, nameKo, brand, category, ingredients))); syncProductIngredients(result); return result; } @@ -61,6 +75,28 @@ public ProductResult getProduct(String id) { return ProductResult.from(repository.findById(id)); } + public ProductResult updateBasicInfo(String id, String name, String nameKo, String brand, String category) { + Product product = repository.findById(id); + return ProductResult.from(repository.save(product.updateBasicInfo(name, nameKo, brand, category))); + } + + public List getProductIngredients(String id) { + repository.findById(id); + return productIngredientRepository == null ? List.of() : productIngredientRepository.findByProductId(id); + } + + public void reviewProductIngredient(String productId, long rowId, String ingredientId, BigDecimal min, + BigDecimal max, String unit, String notes, + java.util.List properties) { + repository.findById(productId); + productIngredientRepository.review(productId, rowId, ingredientId, min, max, unit, notes, properties); + } + + /** 화면이 특성 입력 칸을 그릴 때 쓰는 정의 목록. */ + public java.util.List propertyDefinitions() { + return productIngredientRepository == null ? List.of() : productIngredientRepository.propertyDefinitions(); + } + /** 1단계 검수 — 전성분만 저장한다. 기능성(function)은 그대로 남는다. */ public ProductResult reviewIngredients(String id, List ingredients, boolean ingredientNotFound) { Product product = repository.findById(id); @@ -69,6 +105,16 @@ public ProductResult reviewIngredients(String id, List ingredients, bool return result; } + /** + * 성분별 보완 완료 — 여기서 상태가 INGREDIENTS_ADDED 로 넘어간다. + * + *

전성분 저장({@link #reviewIngredients})과 나눠 둔 이유는 {@code Product} 쪽 주석 참조. + */ + public ProductResult completeIngredientReview(String id) { + Product product = repository.findById(id); + return ProductResult.from(repository.save(product.completeIngredientReview())); + } + /** 2단계 검수 — 식약처 기능성만 저장한다. 빈 목록은 "확인했으나 기능성 아님"이다. */ public ProductResult reviewFunction(String id, List function) { Product product = repository.findById(id); diff --git a/src/main/java/com/seoulection/admin/product/domain/entity/Product.java b/src/main/java/com/seoulection/admin/product/domain/entity/Product.java index 27d3fca..146d02b 100644 --- a/src/main/java/com/seoulection/admin/product/domain/entity/Product.java +++ b/src/main/java/com/seoulection/admin/product/domain/entity/Product.java @@ -27,6 +27,7 @@ public class Product { private final String id; private final String asin; private final String name; + private final String nameKo; private final String brand; private final ProductCategory category; private final String description; @@ -47,6 +48,7 @@ private Product(Builder builder) { this.id = builder.id; this.asin = builder.asin; this.name = requireText(builder.name, "name"); + this.nameKo = builder.nameKo; this.brand = requireText(builder.brand, "brand"); this.category = Objects.requireNonNull(builder.category); this.description = builder.description; @@ -74,14 +76,27 @@ public static Product pending(String name, String brand, String category) { } public static Product pending(String name, String brand, String category, List ingredients) { + return pending(name, null, brand, category, ingredients); + } + + /** + * 등록 시점에 한글 이름까지 받는다. + * + *

검수 단계까지 미루지 않는 이유: 어드민이 제품을 등록할 때는 이미 상세 페이지를 보고 + * 있어서 한글 이름을 알고 있다. 그때 안 받으면 나중에 같은 제품을 다시 열어야 한다. + */ + public static Product pending(String name, String nameKo, String brand, String category, + List ingredients) { boolean hasIngredients = ingredients != null && !ingredients.isEmpty(); return builder() + .nameKo(nameKo == null || nameKo.isBlank() ? null : nameKo.trim()) .name(name) .brand(brand) .category(ProductCategory.from(category)) .ingredients(hasIngredients ? ingredients : null) .ingredientSource(hasIngredients ? ADMIN_SOURCE : null) - .status(hasIngredients ? ProductStatus.INGREDIENTS_ADDED : ProductStatus.PENDING) + // 성분을 같이 넣어도 곧장 기능성으로 보내지 않는다 — 사전 연결·함량이 아직 비어 있다. + .status(hasIngredients ? ProductStatus.NEED_MANUAL_REVIEW : ProductStatus.PENDING) .build(); } @@ -98,6 +113,7 @@ private static String requireText(String value, String fieldName) { public String id() { return id; } public String asin() { return asin; } public String name() { return name; } + public String nameKo() { return nameKo; } public String brand() { return brand; } public String category() { return category.value(); } public String description() { return description; } @@ -128,10 +144,14 @@ public Product reviewIngredients(List ingredients, boolean ingredientNot // 빈 저장은 아무것도 확인하지 못한 것이다. INSUFFICIENT_INGREDIENTS는 "크롤링은 됐는데 // 성분이 5개 미만"이라는 파이프라인의 판정이라 어드민 저장으로 만들어 내면 안 된다. nextStatus = status; - } else if (status.functionalReviewDone()) { - nextStatus = status; + } else if (status == ProductStatus.NOT_FOUND) { + // 못 찾았다고 했다가 성분을 채운 경우. 다시 보완 대기로 돌려놓는다. + nextStatus = ProductStatus.NEED_MANUAL_REVIEW; } else { - nextStatus = ProductStatus.INGREDIENTS_ADDED; + // ⚠️ 여기서 INGREDIENTS_ADDED 로 넘기지 않는다. 전성분을 적은 것과 성분별 보완 + // (사전 연결·함량·특성)까지 마친 것은 다른 일이고, 전자만으로 상태를 옮기면 + // 보완 화면을 아무도 거치지 않는다. 완료 선언은 completeIngredientReview() 다. + nextStatus = status; } boolean cleared = ingredientNotFound || ingredients == null || ingredients.isEmpty(); return toBuilder() @@ -141,6 +161,26 @@ public Product reviewIngredients(List ingredients, boolean ingredientNot .build(); } + /** + * 성분별 보완까지 마쳤다는 선언. 여기서 비로소 {@code INGREDIENTS_ADDED} 가 된다. + * + *

함량·특성을 하나도 안 채웠어도 넘어갈 수 있다 — 채울 값이 없는 제품이 실제로 있고, + * "보완할 게 없음을 확인했다"도 검수 결과이기 때문이다. 막는 건 성분이 아예 없는 경우 + * 하나뿐이다. 그건 아직 1단계도 끝나지 않은 것이다. + * + *

기능성 검수를 이미 지난 제품은 되돌리지 않는다. 성분 하나 고쳤다고 기능성을 다시 + * 보게 만들 이유가 없다. + */ + public Product completeIngredientReview() { + if (ingredients == null || ingredients.isEmpty()) { + throw new IllegalStateException("전성분을 먼저 저장해야 성분 보완을 마칠 수 있습니다."); + } + if (status.functionalReviewDone()) { + return this; + } + return toBuilder().status(ProductStatus.INGREDIENTS_ADDED).build(); + } + /** * 2단계 검수 — 식약처 기능성 결과만 갱신한다. 성분은 손대지 않는다. * @@ -163,9 +203,15 @@ public Product reviewFunction(List function) { return toBuilder().function(function).status(nextStatus).build(); } + public Product updateBasicInfo(String name, String nameKo, String brand, String category) { + return toBuilder().name(name).nameKo(blankToNull(nameKo)).brand(brand).category(category).build(); + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + public Builder toBuilder() { return new Builder() - .id(id).asin(asin).name(name).brand(brand).category(category) + .id(id).asin(asin).name(name).nameKo(nameKo).brand(brand).category(category) .description(description).price(price).thumbnailUrl(thumbnailUrl).productUrl(productUrl) .mentionCount(mentionCount).adRatio(adRatio).adLikelihoodSum(adLikelihoodSum) .ingredientSource(ingredientSource).ingredients(ingredients) @@ -178,6 +224,7 @@ public static class Builder { private String id; private String asin; private String name; + private String nameKo; private String brand; private ProductCategory category; private String description; @@ -197,6 +244,7 @@ public static class Builder { public Builder id(String v) { this.id = v; return this; } public Builder asin(String v) { this.asin = v; return this; } public Builder name(String v) { this.name = v; return this; } + public Builder nameKo(String v) { this.nameKo = v; return this; } public Builder brand(String v) { this.brand = v; return this; } public Builder category(ProductCategory v) { this.category = v; return this; } public Builder category(String v) { this.category = ProductCategory.from(v); return this; } diff --git a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java new file mode 100644 index 0000000..df81793 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -0,0 +1,152 @@ +package com.seoulection.admin.product.functional.application; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** {@code admin.functional-screening.*} 설정. 기본값은 가장 보수적인 쪽으로 둔다. */ +@ConfigurationProperties(prefix = "admin.functional-screening") +public class FunctionalScreeningProperties { + + private boolean enabled = true; + + /** + * 자동 판정 결과로 제품 상태까지 바로 옮길지. 기본은 꺼짐. + * + *

자동 조회는 근거를 모아 폼을 채워 두는 데까지만 하고, 확정은 어드민이 누른다. + * 기능성은 규제 정보라 "모델이 골랐다"와 "사람이 확인했다" 사이에 한 칸이 있어야 한다 — + * 자동 판정이 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 비용보다 훨씬 크다. + * + *

켜면 판정이 끝나는 즉시 상태가 전진한다. 일치율을 충분히 확인한 뒤에 켤 스위치다. + */ + private boolean applyDecisions = false; + + /** + * 자동 확정에 필요한 점수. 이름이 이 정도로 같으면 판정을 부르지 않고 확정 후보로 본다. + * + *

다만 점수 하나로 확정되지는 않는다 — 숫자 토큰 일치, 업체명 연결, 기능성 유형 도출을 + * 모두 통과해야 한다({@code MfdsCandidate.confirmable}). 실측에서 "닥터디퍼런트 311 + * 모이스처라이저"가 등록명 "닥터디퍼런트131모이스처라이저"와 0.94 였는데, 그건 숫자 + * 규칙에서 걸린다. + */ + private double autoThreshold = 0.95; + + /** 이 점수 미만은 후보로도 남기지 않는다. 표를 후보로 채워 놓으면 검수가 더 느려진다. */ + private double candidateThreshold = 0.60; + + /** + * 우리가 적은 이름이 등록명에 이 비율 이상 담겨 있으면, 유사도가 낮아도 후보로 남긴다. + * 제품명을 일부만 입력한 경우를 건지기 위한 값이다. + */ + private double coverageThreshold = 0.95; + + /** 화면에 남길 후보 수. 이름을 일부만 적으면 같은 계열이 여럿 걸려 넉넉해야 한다. */ + private int maxCandidates = 8; + + /** + * 판정에 넘길 등록 건 수. 화면에 남기는 수보다 넉넉하게 둔다. + * + *

유사도는 표기가 크게 다른 제품을 통째로 떨어뜨린다. 고르는 일을 판정에 맡기려면 + * 임계값을 통과한 것만이 아니라 브랜드 등록 목록을 그대로 보여 줘야 한다. + */ + private int judgePoolSize = 20; + + /** 브랜드 한글 표기 후보 수. 표기가 갈리는 브랜드가 있어 전부 조회해 합친다. */ + private int maxBrandAliases = 4; + + /** 등록명 검색어 상한. 상한은 안전나라 쿼터 때문이고, 많을수록 회수율은 올라간다. */ + private int maxSearchTerms = 8; + + /** + * "기능성 아님"까지 자동 확정할지. 기본은 꺼짐. + * + *

검색 실패와 기능성 아님은 겉보기가 같다(둘 다 후보 0건). 브랜드 전수 조회가 0건일 + * 때만 둘을 가를 수 있고, 그마저도 선크림 같은 제품엔 적용하지 않는다. + */ + private boolean autoConcludeNone = false; + + private final Mfds mfds = new Mfds(); + private final Llm llm = new Llm(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isApplyDecisions() { return applyDecisions; } + public void setApplyDecisions(boolean applyDecisions) { this.applyDecisions = applyDecisions; } + public double getAutoThreshold() { return autoThreshold; } + public void setAutoThreshold(double autoThreshold) { this.autoThreshold = autoThreshold; } + public double getCandidateThreshold() { return candidateThreshold; } + public void setCandidateThreshold(double candidateThreshold) { this.candidateThreshold = candidateThreshold; } + public int getMaxBrandAliases() { return maxBrandAliases; } + public void setMaxBrandAliases(int value) { this.maxBrandAliases = value; } + public int getMaxSearchTerms() { return maxSearchTerms; } + public void setMaxSearchTerms(int value) { this.maxSearchTerms = value; } + public int getJudgePoolSize() { return judgePoolSize; } + public void setJudgePoolSize(int value) { this.judgePoolSize = value; } + public double getCoverageThreshold() { return coverageThreshold; } + public void setCoverageThreshold(double value) { this.coverageThreshold = value; } + public int getMaxCandidates() { return maxCandidates; } + public void setMaxCandidates(int maxCandidates) { this.maxCandidates = maxCandidates; } + public boolean isAutoConcludeNone() { return autoConcludeNone; } + public void setAutoConcludeNone(boolean autoConcludeNone) { this.autoConcludeNone = autoConcludeNone; } + public Mfds getMfds() { return mfds; } + public Llm getLlm() { return llm; } + + public static class Mfds { + /** + * data.go.kr 발급 키. 심사·보고 두 서비스가 같은 키를 쓴다. + * + *

URL 인코딩된 형태 그대로 넣는다({@code ...%2FXm1Lv...%3D%3D}). + * 비어 있으면 조회가 전부 FAILED로 남는다. + */ + private String serviceKey = ""; + private String reportUrl = "https://apis.data.go.kr/1471000/FtnltCosmRptPrdlstInfoService/getRptPrdlstInq"; + private String examUrl = "https://apis.data.go.kr/1471057/FtnltCosmSrngPrdlstInfoService04/getSrngPrdlstInq"; + private int pageSize = 500; + /** 브랜드 전수 조회 페이지 상한. 큰 브랜드(메디큐브 226건)를 담되 무한 페이징은 막는다. */ + private int maxBrandPages = 4; + private int connectTimeoutMs = 5000; + private int readTimeoutMs = 15000; + + public String getServiceKey() { return serviceKey; } + public void setServiceKey(String serviceKey) { this.serviceKey = serviceKey; } + public String getReportUrl() { return reportUrl; } + public void setReportUrl(String reportUrl) { this.reportUrl = reportUrl; } + public String getExamUrl() { return examUrl; } + public void setExamUrl(String examUrl) { this.examUrl = examUrl; } + public int getPageSize() { return pageSize; } + public void setPageSize(int pageSize) { this.pageSize = pageSize; } + public int getMaxBrandPages() { return maxBrandPages; } + public void setMaxBrandPages(int maxBrandPages) { this.maxBrandPages = maxBrandPages; } + public int getConnectTimeoutMs() { return connectTimeoutMs; } + public void setConnectTimeoutMs(int connectTimeoutMs) { this.connectTimeoutMs = connectTimeoutMs; } + public int getReadTimeoutMs() { return readTimeoutMs; } + public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; } + } + + public static class Llm { + private boolean enabled = false; + + /** Gemini 모델. 이름 표기 변환·후보 판정은 무거운 추론이 아니라 flash로 충분하다. */ + private String model = "gemini-3.8-flash"; + + /** Google AI Studio 발급 키. 비어 있으면 LLM 호출을 건너뛰고 규칙만으로 판정한다. */ + private String apiKey = ""; + + /** Gemini Interactions API 엔드포인트. */ + private String baseUrl = "https://generativelanguage.googleapis.com/v1beta/interactions"; + + private int connectTimeoutMs = 5000; + private int readTimeoutMs = 20000; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + public String getApiKey() { return apiKey; } + public void setApiKey(String apiKey) { this.apiKey = apiKey; } + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } + public int getConnectTimeoutMs() { return connectTimeoutMs; } + public void setConnectTimeoutMs(int value) { this.connectTimeoutMs = value; } + public int getReadTimeoutMs() { return readTimeoutMs; } + public void setReadTimeoutMs(int value) { this.readTimeoutMs = value; } + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java new file mode 100644 index 0000000..de5a421 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -0,0 +1,399 @@ +package com.seoulection.admin.product.functional.application; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.port.CandidateVerdict; +import com.seoulection.admin.product.functional.application.port.FunctionalScreeningRepository; +import com.seoulection.admin.product.functional.application.port.MfdsCatalogPort; +import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; +import com.seoulection.admin.product.functional.application.port.ScreeningTarget; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.ItemName; +import com.seoulection.admin.product.functional.domain.MfdsCandidate; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 기능성 자동 판정의 오케스트레이션. 판정 규칙이 흩어지지 않도록 결정은 전부 여기서 + * 내리고, 조회(안전나라)와 이름 해석(LLM)은 포트에 맡긴다. + * + *

조회 순서가 이 모양인 이유: + *

    + *
  1. 정규화한 브랜드+제품명으로 바로 검색 — 되는 건 여기서 끝난다(실측 35%).
  2. + *
  3. 등록명 후보로 재검색 — {@code 비타C→비타씨} 같은 표기 차이를 넘는다.
  4. + *
  5. 브랜드 전수 조회 — 등록명이 아무리 달라도 그 브랜드 목록 안에는 들어 있다. + * 회수율의 나머지 절반이 여기서 나온다. 덤으로 "브랜드 등록 0건"이라는 음성 근거와 + * 업체명(ENTP_NAME) 기준값을 얻는다.
  6. + *
+ */ +@Service +public class FunctionalScreeningService { + + private static final Logger log = LoggerFactory.getLogger(FunctionalScreeningService.class); + + /** 자동으로 "기능성 아님"을 확정하면 안 되는 카테고리. 선크림은 법적으로 기능성이어야 한다. */ + private static final List FUNCTIONAL_BY_LAW_CATEGORIES = List.of("sunscreens"); + + /** 이름에 이게 들어 있으면 기능성일 공산이 크다 — 후보 0건이어도 검색 실패로 본다. */ + private static final List FUNCTIONAL_HINTS = + List.of("선크림", "선스틱", "선쿠션", "선세럼", "자차", "톤업", "미백", "주름", "브라이트닝", + "sun", "spf", "uv", "whitening", "brightening", "wrinkle"); + + private final ProductService productService; + private final MfdsCatalogPort mfdsCatalog; + private final ProductNameResolverPort nameResolver; + private final FunctionalScreeningRepository repository; + private final FunctionalScreeningProperties properties; + + /** 브랜드 한글 표기는 브랜드 단위로만 달라진다 — 제품마다 LLM을 부르지 않기 위한 캐시. */ + private final Map> brandAliasCache = new java.util.concurrent.ConcurrentHashMap<>(); + + public FunctionalScreeningService(ProductService productService, + MfdsCatalogPort mfdsCatalog, + ProductNameResolverPort nameResolver, + FunctionalScreeningRepository repository, + FunctionalScreeningProperties properties) { + this.productService = productService; + this.mfdsCatalog = mfdsCatalog; + this.nameResolver = nameResolver; + this.repository = repository; + this.properties = properties; + } + + public Optional find(String productId) { + return repository.findByProductId(productId); + } + + public boolean isEnabled() { + return properties.isEnabled(); + } + + /** 자동 판정이 상태까지 옮기는 모드인가. 화면 문구와 이동 경로가 이걸로 갈린다. */ + public boolean appliesDecisions() { + return properties.isApplyDecisions(); + } + + /** + * 자동화의 진입점. 어드민이 한글 이름을 저장한 직후에 불린다. + * + *

왜 하필 여기인가: 등록명(ITEM_NAME)은 전부 한글이고 브랜드 한글 표기로 시작한다. + * 영문 제품명만으로는 조회가 시작조차 안 되고, 한글 이름이 채워진 그 순간이 자동 조회가 + * 가장 잘 듣는 시점이다. 그래서 4단계 마법사의 "한글 이름" 저장이 곧 기능성 조회 트리거다. + * + *

결과가 나오면 기능성까지 확정돼 다음 단계로 넘어가고, 못 찾으면 기능성 폼이 열린 채 + * 후보만 채워진다 — 그때만 사람이 고른다. + */ + public Optional screenAfterNameSaved(String productId) { + if (!properties.isEnabled()) { + return Optional.empty(); + } + return Optional.of(screen(productId)); + } + + /** + * 화면에서 쓰는 조회 — 판정이 없으면 그때 한 번 돌린다. + * + *

한글 이름이 없으면 돌리지 않는다. 등록명이 전부 한글이라 영문명으로는 어차피 0건이고, + * 그 0건이 "검색 결과 없음"으로 기록되면 사람이 잘못된 근거를 보게 된다. + */ + public Optional findOrScreen(String productId) { + if (!properties.isEnabled()) { + return Optional.empty(); + } + Optional saved = repository.findByProductId(productId); + if (saved.isPresent()) { + return saved; + } + ProductResult product = productService.getProduct(productId); + if (product.nameKo() == null || product.nameKo().isBlank()) { + return Optional.empty(); + } + return Optional.of(screen(product)); + } + + /** + * 어드민이 직접 저장했음을 판정 기록에 남긴다. + * + *

{@code decidedBy}를 남기는 이유: 기능성은 규제 정보라 "이 제품이 왜 이렇게 기록됐나"를 + * 되짚을 수 있어야 하고, 자동이 틀렸던 건들을 모아 규칙을 고칠 때도 이 표시가 열쇠가 된다. + */ + public void markDecidedByAdmin(String productId) { + repository.findByProductId(productId).ifPresent(screening -> repository.save( + new FunctionalScreening(screening.productId(), screening.outcome(), screening.claims(), + screening.candidates(), screening.selectedIndex(), screening.confidence(), + screening.reason(), screening.brandRegistryCount(), + FunctionalScreening.DECIDED_BY_ADMIN, screening.engineVersion(), Instant.now()))); + } + + /** + * 기능성 확인 큐를 한 번에 훑는다. 목록 화면의 [자동 조회 실행] 버튼과 스케줄러가 부른다. + * + * @return 결말별 건수 + */ + public Map screenQueue(int limit) { + Map summary = new LinkedHashMap<>(); + var page = productService.getProducts(List.of(ProductStatus.INGREDIENTS_ADDED), null, 0, Math.max(limit, 1)); + for (ProductResult product : page.content()) { + if (product.nameKo() == null || product.nameKo().isBlank()) { + continue; // 한글 이름이 없으면 조회할 근거가 없다. + } + FunctionalScreening screening = screen(product); + summary.merge(screening.outcome(), 1, Integer::sum); + } + return summary; + } + + public FunctionalScreening screen(String productId) { + return screen(productService.getProduct(productId)); + } + + public FunctionalScreening screen(ProductResult product) { + ScreeningTarget target = ScreeningTarget.from(product); + FunctionalScreening screening; + try { + screening = decide(target); + } catch (RuntimeException e) { + // 조회·판정 실패는 "기능성 아님"이 아니다. 사유만 남기고 큐에 그대로 둔다. + log.warn("기능성 자동 판정 실패 productId={}", target.id(), e); + screening = FunctionalScreening.failed(target.id(), "조회 중 오류: " + e.getMessage()); + } + repository.save(screening); + applyIfDecided(product, screening); + return screening; + } + + // ── 판정 ──────────────────────────────────────────────────────────────── + + private FunctionalScreening decide(ScreeningTarget target) { + List aliases = brandAliases(target.brand()); + String brandKo = aliases.isEmpty() ? target.brand() : aliases.get(0); + + // 브랜드 전수: 등록 목록 + 업체명 기준값 + "0건"이라는 음성 근거를 한 번에 준다. + // + // ⚠️ 첫 별칭에서 멈추지 않는다. 표기가 갈리는 브랜드가 실제로 있고("아누아"/"어누아", + // "넘버즈인"/"넘버즈인"), 한 표기로 몇 건 나왔다고 나머지를 안 보면 정작 맞는 제품이 + // 다른 표기 아래 있을 때 통째로 놓친다. 별칭마다 조회해 합친다. + List brandItems = new ArrayList<>(); + int bestHits = 0; + for (String alias : aliases) { + List found = mfdsCatalog.searchBrand(alias); + brandItems.addAll(found); + // 이름 비교의 기준이 될 표기는 가장 많이 걸린 것으로 둔다 — 그게 실제 등록 표기다. + if (found.size() > bestHits) { + bestHits = found.size(); + brandKo = alias; + } + } + String brandEntpName = dominantEntpName(brandItems); + + List pool = new ArrayList<>(brandItems); + for (String term : searchTerms(target, brandKo)) { + pool.addAll(mfdsCatalog.searchByItemName(term)); + } + + long brandCount = brandItems.size(); + List scored = score(pool, target, brandKo, brandEntpName); + if (scored.isEmpty()) { + // 브랜드 전수를 한글 표기로 못 돌렸으면 "0건"이 음성 근거가 되지 못한다 — + // 등록명은 전부 한글이라 영문 브랜드명으로 조회하면 무조건 0건이 나온다. + return noCandidate(target, brandCount, containsHangul(brandKo)); + } + + // 유사도만으로 확실한 것들. 여기서 끝나면 판정을 부르지 않는다(호출 비용을 아낀다). + List strong = scored.stream() + .filter(candidate -> candidate.score() >= properties.getCandidateThreshold() + || candidate.coverage() >= properties.getCoverageThreshold()) + .limit(properties.getMaxCandidates()) + .toList(); + + // 점수만으로 확정되는 경우(auto-threshold 기본 0.95 + 숫자·업체·유형 규칙 통과). + if (!strong.isEmpty() && strong.get(0).confirmable(properties.getAutoThreshold()) + && !strong.get(0).partialNameMatch()) { + return confirmed(target, strong, 0, "등록명이 거의 일치합니다", brandCount); + } + + // ⚠️ 판정에 임계값을 통과한 것만 넘기지 않는다. 유사도는 표기가 크게 다른 제품을 + // 통째로 떨어뜨린다 — "토리든 다이브인 저분자 히알루론산 세럼"은 이 브랜드 등록 2건과 + // 모두 0.5 미만이라, 예전에는 판정이 아예 호출되지 않고 화면엔 "후보 없음"만 떴다. + // 브랜드 등록 목록을 점수순으로 넉넉히 넘기고 고르는 일을 판정에 맡긴다. + List reviewed = scored.stream().limit(properties.getJudgePoolSize()).toList(); + CandidateVerdict verdict = nameResolver.judge(target, reviewed.stream().map(MfdsCandidate::item).toList()); + List shown = reviewed.stream().limit(properties.getMaxCandidates()).toList(); + + if (!verdict.matched()) { + // 판정이 "없다"고 했다. 유사도 높은 후보가 있었으면 사람이 다시 볼 값어치가 있고, + // 그것마저 없으면 이 브랜드에 이 제품의 등록이 없다는 쪽에 가깝다. 어느 쪽이든 + // 무엇을 보고 그렇게 판단했는지는 화면에 남긴다. + ScreeningOutcome outcome = strong.isEmpty() ? ScreeningOutcome.NOT_MATCHED : ScreeningOutcome.NEEDS_REVIEW; + String head = strong.isEmpty() + ? "이 브랜드 등록 " + brandCount + "건 중 같은 제품을 찾지 못했습니다" + : "같은 제품으로 볼 후보가 없습니다"; + return new FunctionalScreening(target.id(), outcome, List.of(), shown, -1, + verdict.confidence(), head + ": " + verdict.reason(), + brandCount, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); + } + + int index = Math.min(Math.max(verdict.index(), 0), reviewed.size() - 1); + MfdsCandidate chosen = reviewed.get(index); + // 판정이 고른 건이 표시 범위 밖일 수 있다(20개 중 15번째를 골랐다면). 맨 앞에 세운다. + List withChosen = new ArrayList<>(); + withChosen.add(chosen); + shown.stream().filter(candidate -> candidate != chosen) + .limit(Math.max(properties.getMaxCandidates() - 1, 0)) + .forEach(withChosen::add); + + // ⚠️ 판정이 골랐다고 확정하지 않는다. 이름이 완전히 같은 경우(위 분기)를 빼면 확정은 + // 사람 몫이다 — 모델이 고른 근거가 아무리 그럴듯해도 등록명이 다르다는 건 다른 제품일 + // 수 있다는 뜻이고, 기능성은 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 것보다 크다. + // 대신 고른 것을 맨 앞에 세우고 유형까지 채워 둬서, 맞으면 저장만 누르면 되게 한다. + String block = chosen.blockReason(); + String reason = block.isBlank() + ? "가장 가까운 건으로 " + chosen.item().itemName() + " 을(를) 골랐습니다: " + verdict.reason() + : block; + return new FunctionalScreening(target.id(), ScreeningOutcome.NEEDS_REVIEW, chosen.claims().categories(), + withChosen, 0, verdict.confidence(), reason, + brandCount, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); + } + + private FunctionalScreening confirmed(ScreeningTarget target, List candidates, + int index, String reason, long brandCount) { + return new FunctionalScreening(target.id(), ScreeningOutcome.AUTO_CONFIRMED, + candidates.get(index).claims().categories(), candidates, index, CandidateVerdict.HIGH, reason, + brandCount, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); + } + + /** + * 후보가 하나도 없을 때. 여기가 자동화에서 가장 조심할 지점이다 — 검색 실패와 기능성 + * 아님은 겉보기가 같다. 브랜드 등록이 0건일 때만 둘을 가를 수 있다. + */ + private FunctionalScreening noCandidate(ScreeningTarget target, long brandCount, boolean brandLookupReliable) { + boolean brandAbsent = brandCount == 0 && brandLookupReliable; + boolean lawRequiresFunctional = FUNCTIONAL_BY_LAW_CATEGORIES.contains(target.category()); + boolean hintsFunctional = hasFunctionalHint(target); + + if (properties.isAutoConcludeNone() && brandAbsent && !lawRequiresFunctional && !hintsFunctional) { + return new FunctionalScreening(target.id(), ScreeningOutcome.AUTO_NONE, List.of(), List.of(), -1, + CandidateVerdict.HIGH, "이 브랜드의 기능성 등록이 안전나라에 한 건도 없습니다", + 0, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); + } + + String reason; + if (lawRequiresFunctional) { + reason = "자외선 차단 제품은 기능성 등록이 있어야 합니다 — 조회 실패로 보고 직접 확인해 주세요"; + } else if (hintsFunctional) { + reason = "제품명이 기능성을 암시하는데 등록 건을 찾지 못했습니다 — 직접 확인해 주세요"; + } else if (brandAbsent) { + reason = "이 브랜드의 기능성 등록이 한 건도 없습니다(기능성 아님일 가능성이 높습니다)"; + } else if (!brandLookupReliable) { + reason = "브랜드 한글 표기를 몰라 전수 조회를 못 했습니다 — 등록 여부를 직접 확인해 주세요"; + } else { + reason = "브랜드 등록은 " + brandCount + "건 있으나 이 제품과 맞는 건을 찾지 못했습니다"; + } + return new FunctionalScreening(target.id(), ScreeningOutcome.NOT_MATCHED, List.of(), List.of(), -1, + "LOW", reason, brandCount, FunctionalScreening.DECIDED_BY_AUTO, + FunctionalScreening.ENGINE_VERSION, Instant.now()); + } + + /** 자동 확정을 실제 상태 전진으로 옮긴다. 그림자 모드면 판정만 남기고 상태는 그대로 둔다. */ + private void applyIfDecided(ProductResult product, FunctionalScreening screening) { + if (!properties.isApplyDecisions() || !screening.outcome().decided()) { + return; + } + if (product.status() != ProductStatus.INGREDIENTS_ADDED) { + return; // 이미 사람이 손댔거나 파이프라인이 지나간 제품은 건드리지 않는다. + } + productService.reviewFunction(product.id(), + screening.claims().stream().map(ProductFunctionalCategory::code).toList()); + } + + // ── 조회 재료 ──────────────────────────────────────────────────────────── + + private List brandAliases(String brand) { + return brandAliasCache.computeIfAbsent(brand, key -> { + List aliases = new ArrayList<>(nameResolver.koreanBrandAliases(key)); + if (!aliases.contains(key)) { + aliases.add(key); + } + return aliases.stream().filter(value -> value != null && !value.isBlank()) + .distinct().limit(properties.getMaxBrandAliases()).toList(); + }); + } + + private List searchTerms(ScreeningTarget target, String brandKo) { + LinkedHashSet terms = new LinkedHashSet<>(); + terms.add(ItemName.normalize(target.brandedName(brandKo))); + terms.add(ItemName.normalize(target.displayName())); + nameResolver.registrationNameCandidates(target, brandKo).stream() + .map(ItemName::normalize) + .forEach(terms::add); + terms.removeIf(term -> term.length() < 2); + // 상한을 두는 건 안전나라 쿼터 때문이지 정확도 때문이 아니다 — 후보가 많을수록 회수율은 + // 올라간다. 기본 8개는 "정규화 2 + 모델이 만든 표기 변형 여러 개"를 담는 크기다. + return terms.stream().limit(properties.getMaxSearchTerms()).toList(); + } + + /** + * 브랜드 등록 목록에서 가장 많이 나온 업체명. 후보의 {@code ENTP_NAME}을 견주는 기준값이다. + * + *

LLM에게 법인명을 물어보지 않는 이유가 여기 있다 — 안전나라가 직접 알려주는 값이라 + * 모델의 기억보다 정확하다. + */ + private String dominantEntpName(List brandItems) { + return brandItems.stream() + .map(MfdsItem::entpName) + .filter(name -> name != null && !name.isBlank()) + .collect(java.util.stream.Collectors.groupingBy(name -> name, java.util.stream.Collectors.counting())) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); + } + + /** + * 취하 건을 걸러 내고 중복을 합친 뒤 점수순으로 세운다. 임계값은 보지 않는다. + * + *

거르는 일을 여기서 하지 않는 이유: 유사도는 표기가 크게 다른 제품을 통째로 떨어뜨린다. + * 무엇을 후보로 볼지는 호출하는 쪽이 정한다. + */ + private List score(List pool, ScreeningTarget target, + String brandKo, String brandEntpName) { + String query = target.brandedName(brandKo); + Map unique = new LinkedHashMap<>(); + for (MfdsItem item : pool) { + if (item.canceled()) { + continue; // 취하된 등록은 근거가 되지 못한다. + } + MfdsCandidate candidate = MfdsCandidate.of(item, query, brandKo, brandEntpName); + String key = item.source() + "|" + ItemName.normalize(item.itemName()) + "|" + item.entpName(); + unique.merge(key, candidate, + (existing, incoming) -> existing.claims().categories().size() >= incoming.claims().categories().size() + ? existing : incoming); + } + return unique.values().stream() + .sorted(Comparator.comparingDouble(MfdsCandidate::score).reversed()) + .toList(); + } + + private boolean containsHangul(String value) { + return value != null && value.chars().anyMatch(ch -> ch >= 0xAC00 && ch <= 0xD7A3); + } + + private boolean hasFunctionalHint(ScreeningTarget target) { + String haystack = (target.name() + " " + (target.nameKo() == null ? "" : target.nameKo())).toLowerCase(); + return FUNCTIONAL_HINTS.stream().anyMatch(haystack::contains); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/application/port/CandidateVerdict.java b/src/main/java/com/seoulection/admin/product/functional/application/port/CandidateVerdict.java new file mode 100644 index 0000000..b65d2bd --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/port/CandidateVerdict.java @@ -0,0 +1,25 @@ +package com.seoulection.admin.product.functional.application.port; + +/** + * "후보 중 무엇이 같은 제품인가"에 대한 판정. + * + * @param index 후보 목록의 인덱스. 같은 제품이 없으면 -1. + * @param confidence HIGH / MEDIUM / LOW. HIGH일 때만 자동 확정 후보가 된다(그마저도 + * 숫자 토큰·기능성 도출 규칙을 다시 통과해야 한다). + */ +public record CandidateVerdict(int index, String confidence, String reason) { + + public static final String HIGH = "HIGH"; + + public static CandidateVerdict none(String reason) { + return new CandidateVerdict(-1, "LOW", reason); + } + + public boolean matched() { + return index >= 0; + } + + public boolean high() { + return HIGH.equalsIgnoreCase(confidence); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/application/port/FunctionalScreeningRepository.java b/src/main/java/com/seoulection/admin/product/functional/application/port/FunctionalScreeningRepository.java new file mode 100644 index 0000000..e4b32dd --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/port/FunctionalScreeningRepository.java @@ -0,0 +1,12 @@ +package com.seoulection.admin.product.functional.application.port; + +import com.seoulection.admin.product.functional.domain.FunctionalScreening; + +import java.util.Optional; + +public interface FunctionalScreeningRepository { + + void save(FunctionalScreening screening); + + Optional findByProductId(String productId); +} diff --git a/src/main/java/com/seoulection/admin/product/functional/application/port/MfdsCatalogPort.java b/src/main/java/com/seoulection/admin/product/functional/application/port/MfdsCatalogPort.java new file mode 100644 index 0000000..3a5b96a --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/port/MfdsCatalogPort.java @@ -0,0 +1,26 @@ +package com.seoulection.admin.product.functional.application.port; + +import com.seoulection.admin.product.functional.domain.MfdsItem; + +import java.util.List; + +/** + * 의약품안전나라 기능성화장품 조회. + * + *

메서드가 두 개인 이유는 조회 방식이 둘이기 때문이다. 하나는 등록명 후보로 찍어 보는 + * 검색이고, 다른 하나는 브랜드 이름만 넣어 그 브랜드 등록 목록을 통째로 받는 것이다. + * 후자가 회수율의 절반을 책임진다 — 등록명 표기가 우리 제품명과 달라도 브랜드 목록 안에는 + * 반드시 들어 있기 때문이다. + * + *

⚠️ {@code entp_name}·{@code bizrno} 같은 파라미터는 API가 무시한다(실측: 전체 + * 건수가 그대로 나온다). 필터로 동작하는 건 {@code item_name} 하나뿐이라, 업체명은 조회 + * 조건이 아니라 응답 검증에만 쓴다. + */ +public interface MfdsCatalogPort { + + /** 등록명 후보 하나로 심사·보고를 모두 조회한다. 한 페이지면 충분한 좁은 검색이다. */ + List searchByItemName(String term); + + /** 브랜드 한글명으로 등록 목록 전체를 받는다(페이지 상한까지). 없으면 빈 목록. */ + List searchBrand(String brandKo); +} diff --git a/src/main/java/com/seoulection/admin/product/functional/application/port/ProductNameResolverPort.java b/src/main/java/com/seoulection/admin/product/functional/application/port/ProductNameResolverPort.java new file mode 100644 index 0000000..10b6be5 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/port/ProductNameResolverPort.java @@ -0,0 +1,40 @@ +package com.seoulection.admin.product.functional.application.port; + +import com.seoulection.admin.product.functional.domain.MfdsItem; + +import java.util.List; + +/** + * 이름 문제를 푸는 자리. 기능성 판단은 여기서 하지 않는다 — 유형은 오직 안전나라 + * 응답에서만 나온다({@code FunctionalClaims}). 이 포트가 답하는 건 "무엇으로 검색할지"와 + * "이 후보가 같은 제품인지"뿐이다. + * + *

구현이 둘이다. LLM 구현({@code ClaudeProductNameResolver})과 규칙 기반 대체 + * 구현({@code HeuristicProductNameResolver}). 설정 한 줄로 갈리고, LLM이 꺼져 있어도 + * 자동화 전체가 동작한다 — 회수율만 낮아진다. + */ +public interface ProductNameResolverPort { + + /** + * 브랜드의 한글 표기 후보. {@code Goodal → 구달}, {@code d'Alba → 달바}. + * + *

브랜드 단위라 캐시가 잘 듣는다. 제품마다 부르지 말 것. + */ + List koreanBrandAliases(String brand); + + /** + * 안전나라 등록명 후보. {@code 구달 청귤 비타C 잡티 세럼 → 구달청귤비타씨잡티세럼}. + * + *

등록명은 유통명과 표기가 다르다({@code 비타C→비타씨}, {@code 10%→10퍼센트}). + * 정규화로는 못 넘는 벽이고, 검색 실패의 대부분이 여기서 난다. + */ + List registrationNameCandidates(ScreeningTarget target, String brandKo); + + /** + * 후보 중 같은 제품을 고른다. + * + *

유사도로는 못 거르는 것들이 있다 — 실측에서 {@code 311 모이스처라이저}가 + * {@code 131모이스처라이저}와 0.94로 붙었다. 숫자 규칙이 1차 방어이고, 이 판정이 2차다. + */ + CandidateVerdict judge(ScreeningTarget target, List candidates); +} diff --git a/src/main/java/com/seoulection/admin/product/functional/application/port/ScreeningTarget.java b/src/main/java/com/seoulection/admin/product/functional/application/port/ScreeningTarget.java new file mode 100644 index 0000000..e34dc8a --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/port/ScreeningTarget.java @@ -0,0 +1,43 @@ +package com.seoulection.admin.product.functional.application.port; + +import com.seoulection.admin.product.application.dto.ProductResult; + +import java.util.List; + +/** + * 판정 대상 제품에서 조회에 필요한 것만 추린 값. 포트가 {@code ProductResult} 전체(18필드)를 + * 알 필요는 없고, LLM 프롬프트에 통째로 실어 보낼 이유는 더더욱 없다. + */ +public record ScreeningTarget( + String id, + String name, + String nameKo, + String brand, + String category, + List ingredients +) { + public static ScreeningTarget from(ProductResult product) { + return new ScreeningTarget(product.id(), product.name(), product.nameKo(), + product.brand(), product.category(), + product.ingredients() == null ? List.of() : product.ingredients()); + } + + /** 조회·비교의 기준이 되는 이름. 한글명이 있으면 그게 등록명에 가깝다. */ + public String displayName() { + return nameKo == null || nameKo.isBlank() ? name : nameKo; + } + + /** + * 등록명은 대개 브랜드로 시작한다 — 비교 대상도 같은 모양으로 만든다. + * + *

⚠️ 이름이 이미 브랜드로 시작하면 붙이지 않는다. 어드민은 한글 이름을 "달바 워터풀"처럼 + * 브랜드까지 넣어 적는 경우가 많고, 그때 앞에 또 붙이면 "달바달바워터풀"로 조회돼 + * 한 건도 안 나온다. + */ + public String brandedName(String brandKo) { + String prefix = brandKo == null || brandKo.isBlank() ? brand : brandKo; + String name = displayName(); + return com.seoulection.admin.product.functional.domain.ItemName.startsWithBrand(name, prefix) + ? name : prefix + name; + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/ClaimReading.java b/src/main/java/com/seoulection/admin/product/functional/domain/ClaimReading.java new file mode 100644 index 0000000..793e305 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/ClaimReading.java @@ -0,0 +1,34 @@ +package com.seoulection.admin.product.functional.domain; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; + +import java.util.List; + +/** + * 안전나라 한 행에서 읽어 낸 기능성 유형과, 읽어 내지 못한 사유. + * + * @param categories 우리 6분류로 옮겨진 유형 + * @param outOfScope 기능성이긴 한데 우리 분류 밖(염모·탈색·제모 등) + * @param derivable 유형을 도출할 근거가 응답에 있었는가 + */ +public record ClaimReading(List categories, boolean outOfScope, boolean derivable) { + + public static ClaimReading none(boolean outOfScope, boolean derivable) { + return new ClaimReading(List.of(), outOfScope, derivable); + } + + /** 이 행만으로 자동 확정해도 되는가. 분류 밖이거나 근거가 없으면 사람이 봐야 한다. */ + public boolean autoConfirmable() { + return derivable && !outOfScope && !categories.isEmpty(); + } + + public String reason() { + if (outOfScope) { + return "기능성이지만 우리 분류 밖(염모·탈색 등)입니다"; + } + if (!derivable) { + return "등록 건에 효능효과(EE_NAME)도 SPF/PA도 없어 유형을 도출할 수 없습니다"; + } + return ""; + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/FunctionalClaims.java b/src/main/java/com/seoulection/admin/product/functional/domain/FunctionalClaims.java new file mode 100644 index 0000000..46dccd4 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/FunctionalClaims.java @@ -0,0 +1,69 @@ +package com.seoulection.admin.product.functional.domain; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 안전나라 응답 → {@link ProductFunctionalCategory} 변환. 유형의 유일한 출처다. + * + *

LLM은 여기에 관여하지 않는다. 검색어를 만들고 후보를 고르는 데까지만 쓰고, "이 제품이 + * 무슨 기능성인가"는 반드시 API 응답 필드에서만 나온다 — 규제 정보를 모델이 지어내지 + * 못하게 하는 경계다. + * + *

{@code COSMETIC_TARGET_FLAG_NAME}(제10조 제1항 제○호)을 쓰지 않는 이유: 그건 효능이 + * 아니라 보고 근거 구분이다. 실측하면 제1호 안에 미백·주름·염모·탈색이 전부 섞여 있다. + */ +public final class FunctionalClaims { + + private FunctionalClaims() { + } + + /** 우리 분류 밖이지만 명백히 기능성인 효능. 이게 걸리면 자동 확정하지 않는다. */ + private static final List OUT_OF_SCOPE_KEYWORDS = List.of("염모", "탈색", "탈염", "제모", "탈모", "산화제"); + + public static ClaimReading read(MfdsItem item) { + String ee = item.eeName() == null ? "" : item.eeName(); + + if (!ee.isBlank()) { + Set categories = new LinkedHashSet<>(); + if (ee.contains("미백")) { + categories.add(ProductFunctionalCategory.WHITENING); + } + if (ee.contains("주름")) { + categories.add(ProductFunctionalCategory.WRINKLE_IMPROVEMENT); + } + if (ee.contains("자외선") || ee.contains("자외선차단")) { + categories.add(ProductFunctionalCategory.UV_PROTECTION); + } + if (ee.contains("여드름")) { + categories.add(ProductFunctionalCategory.ACNE_RELIEF); + } + if (ee.contains("아토피") || ee.contains("피부장벽")) { + categories.add(ProductFunctionalCategory.SKIN_BARRIER_RECOVERY); + } + if (ee.contains("튼살")) { + categories.add(ProductFunctionalCategory.STRETCH_MARKS); + } + // SPF/PA가 붙어 있으면 문구에 '자외선'이 없어도 자외선 차단이다. + if (item.hasUvMeasurement()) { + categories.add(ProductFunctionalCategory.UV_PROTECTION); + } + if (!categories.isEmpty()) { + return new ClaimReading(List.copyOf(categories), false, true); + } + boolean outOfScope = OUT_OF_SCOPE_KEYWORDS.stream().anyMatch(ee::contains); + return ClaimReading.none(outOfScope, true); + } + + // EE_NAME이 비어 있는 행이 실제로 많다. SPF/PA가 있으면 선크림 보고 건의 전형이다. + if (item.hasUvMeasurement()) { + return new ClaimReading(List.of(ProductFunctionalCategory.UV_PROTECTION), false, true); + } + + // 등록은 돼 있는데 유형을 알 수 없는 경우 — 여기서 멈추고 사람에게 넘긴다. + return ClaimReading.none(false, false); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/FunctionalScreening.java b/src/main/java/com/seoulection/admin/product/functional/domain/FunctionalScreening.java new file mode 100644 index 0000000..1139305 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/FunctionalScreening.java @@ -0,0 +1,49 @@ +package com.seoulection.admin.product.functional.domain; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; + +import java.time.Instant; +import java.util.List; + +/** + * 제품 한 건의 자동 판정 결과. {@code functional_screenings} 컬렉션에 제품당 1건 남는다. + * + *

{@code ProductStatus}에 새 상태를 만들지 않는 이유: 지금 설계에서 status가 답하는 + * 질문은 "지금 누가 움직여야 하는가"뿐이고, 판정의 내막(후보가 무엇이었고 왜 자동 확정을 + * 못 했는지)은 상태값으로 표현할 성질이 아니다. status는 그대로 두고 내막만 여기 담는다. + * + * @param brandRegistryCount 브랜드 전수 조회 건수. 0이면 "이 브랜드는 기능성 등록 자체가 + * 없다"는 뜻이라 기능성 아님의 근거가 된다. + * @param engineVersion 판정 규칙 버전. 규칙을 고쳤을 때 재판정 대상을 고르는 열쇠다. + */ +public record FunctionalScreening( + String productId, + ScreeningOutcome outcome, + List claims, + List candidates, + int selectedIndex, + String confidence, + String reason, + long brandRegistryCount, + String decidedBy, + String engineVersion, + Instant screenedAt +) { + + public static final String ENGINE_VERSION = "2026-09-08"; + public static final String DECIDED_BY_AUTO = "AUTO"; + public static final String DECIDED_BY_ADMIN = "ADMIN"; + + public static FunctionalScreening failed(String productId, String reason) { + return new FunctionalScreening(productId, ScreeningOutcome.FAILED, List.of(), List.of(), -1, + "NONE", reason, 0, DECIDED_BY_AUTO, ENGINE_VERSION, Instant.now()); + } + + public MfdsCandidate selected() { + return selectedIndex < 0 || selectedIndex >= candidates.size() ? null : candidates.get(selectedIndex); + } + + public boolean hasCandidates() { + return !candidates.isEmpty(); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java b/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java new file mode 100644 index 0000000..97c3034 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java @@ -0,0 +1,108 @@ +package com.seoulection.admin.product.functional.domain; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 안전나라 등록명(ITEM_NAME)과 우리 제품명을 견주기 위한 문자열 도구. + * + *

등록명은 공백·기호가 전부 빠진 한 덩어리다("구달청귤비타씨잡티세럼"). 그래서 비교 + * 전에 양쪽을 같은 규칙으로 눌러 놓아야 한다. + */ +public final class ItemName { + + /** 대괄호·소괄호 안은 통째로 버린다 — "[SPF50+/PA++++]", "(보)" 같은 꼬리표다. */ + private static final Pattern BRACKET = Pattern.compile("[\\[(].*?[\\])]"); + private static final Pattern NON_ALNUM = Pattern.compile("[^가-힣a-zA-Z0-9]"); + private static final Pattern DIGITS = Pattern.compile("\\d+"); + + private ItemName() { + } + + /** 비교용 정규형. 괄호 제거 → 한글/영문/숫자만 남김 → 소문자. */ + public static String normalize(String value) { + if (value == null) { + return ""; + } + String stripped = BRACKET.matcher(value).replaceAll(""); + return NON_ALNUM.matcher(stripped).replaceAll("").toLowerCase(); + } + + /** + * 0~1 유사도. 최장 공통 부분수열 기반이다 — 등록명은 중간에 단어가 끼어드는 경우가 + * 많아("딥클린아크네포밍클렌저") 편집거리보다 부분수열이 실제와 잘 맞는다. + */ + public static double similarity(String left, String right) { + String a = normalize(left); + String b = normalize(right); + if (a.isEmpty() || b.isEmpty()) { + return 0.0; + } + return 2.0 * longestCommonSubsequence(a, b) / (a.length() + b.length()); + } + + /** + * 우리 이름이 등록명에 얼마나 담겨 있는가(0~1). {@link #similarity}가 양쪽 길이를 모두 + * 보는 것과 달리 우리 쪽 길이로만 나눈다. + * + *

왜 필요한가: 어드민이 "달바 워터풀"까지만 적으면 등록명 "달바워터풀톤업선크림"과의 + * 유사도는 0.59다. 임계값에 걸려 후보에서 잘리는데, 정작 우리가 적은 이름은 등록명에 + * 통째로 들어 있다. 이럴 때 답은 "후보 없음"이 아니라 "이 브랜드의 이런 제품들이 + * 있다"를 보여 주고 사람이 고르게 하는 것이다. + * + *

대신 자동 확정에는 쓰지 않는다 — 부분 이름은 여러 변형(선크림·선쿠션·커버베이지 21호…)에 + * 똑같이 1.0이 나오므로 무엇 하나를 고를 근거가 못 된다. + */ + public static double coverage(String query, String candidate) { + String a = normalize(query); + String b = normalize(candidate); + if (a.isEmpty() || b.isEmpty()) { + return 0.0; + } + return (double) longestCommonSubsequence(a, b) / a.length(); + } + + /** + * 이름 안의 숫자 토큰. 자동 확정을 막는 하드 룰의 근거다. + * + *

실측에서 "닥터디퍼런트 311 모이스처라이저"가 등록명 "닥터디퍼런트131모이스처라이저"와 + * 0.94로 붙었다. 숫자 한 자리가 다른 완전히 다른 제품인데 유사도로는 걸러지지 + * 않는다. 그래서 숫자 집합이 다르면 점수가 아무리 높아도 자동 확정하지 않는다. + */ + public static Set numericTokens(String value) { + Set tokens = new LinkedHashSet<>(); + Matcher matcher = DIGITS.matcher(normalize(value)); + while (matcher.find()) { + tokens.add(matcher.group()); + } + return tokens; + } + + /** 한쪽에만 있는 숫자가 있으면 false. 양쪽 다 숫자가 없으면 true. */ + public static boolean numericTokensMatch(String left, String right) { + return numericTokens(left).equals(numericTokens(right)); + } + + public static boolean startsWithBrand(String itemName, String brand) { + String normalizedBrand = normalize(brand); + return !normalizedBrand.isEmpty() && normalize(itemName).startsWith(normalizedBrand); + } + + private static int longestCommonSubsequence(String a, String b) { + int[] previous = new int[b.length() + 1]; + int[] current = new int[b.length() + 1]; + for (int i = 1; i <= a.length(); i++) { + for (int j = 1; j <= b.length(); j++) { + current[j] = a.charAt(i - 1) == b.charAt(j - 1) + ? previous[j - 1] + 1 + : Math.max(previous[j], current[j - 1]); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[b.length()]; + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java new file mode 100644 index 0000000..5eee7be --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java @@ -0,0 +1,59 @@ +package com.seoulection.admin.product.functional.domain; + +/** + * 점수가 매겨진 후보 한 건. 화면의 후보 표가 이 값을 그대로 그린다. + * + * @param numericMatch 숫자 토큰이 일치하는가. false면 점수와 무관하게 자동 확정하지 않는다. + * @param brandMatch 등록명이 브랜드로 시작하거나 업체명이 브랜드 등록 업체와 같은가. + */ +public record MfdsCandidate( + MfdsItem item, + double score, + double coverage, + ClaimReading claims, + boolean numericMatch, + boolean brandMatch +) { + public static MfdsCandidate of(MfdsItem item, String queryName, String brand, String brandEntpName) { + boolean entpMatch = brandEntpName != null && brandEntpName.equals(item.entpName()); + return new MfdsCandidate( + item, + ItemName.similarity(queryName, item.itemName()), + ItemName.coverage(queryName, item.itemName()), + FunctionalClaims.read(item), + ItemName.numericTokensMatch(queryName, item.itemName()), + entpMatch || ItemName.startsWithBrand(item.itemName(), brand)); + } + + /** 점수만으로 자동 확정해도 되는 후보인가. LLM 판정과는 별개로 항상 함께 본다. */ + public boolean confirmable(double autoThreshold) { + return score >= autoThreshold && numericMatch && brandMatch && claims.autoConfirmable(); + } + + /** + * 이름을 부분만 적어 후보로 남은 건가. 화면이 "왜 이게 후보인지"를 설명할 때 쓴다. + * + *

이런 후보는 자동 확정되지 않는다 — "달바 워터풀"에는 선크림·선쿠션·커버베이지 21호가 + * 모두 1.0으로 걸려서 무엇 하나를 고를 근거가 없다. + */ + public boolean partialNameMatch() { + return coverage >= 0.95 && score < 0.9; + } + + /** 자동 확정을 막은 이유. 화면에 그대로 보여 준다. */ + public String blockReason() { + if (!numericMatch) { + return "제품명 숫자가 다릅니다(" + ItemName.numericTokens(item.itemName()) + ") — 다른 제품일 수 있습니다"; + } + if (!brandMatch) { + return "등록 업체(" + item.entpName() + ")가 이 브랜드와 이어지지 않습니다"; + } + if (!claims.autoConfirmable()) { + return claims.reason(); + } + if (partialNameMatch()) { + return "제품명을 일부만 입력해 같은 계열이 여러 건 걸립니다 — 맞는 것을 골라 주세요"; + } + return ""; + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/MfdsItem.java b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsItem.java new file mode 100644 index 0000000..196f6d2 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsItem.java @@ -0,0 +1,30 @@ +package com.seoulection.admin.product.functional.domain; + +/** + * 안전나라 응답 한 행에서 우리가 쓰는 것만 추린 값. + * + *

필드가 왜 이것들인지: {@code itemName}·{@code entpName}은 매칭에, {@code eeName}· + * {@code spf}·{@code pa}는 기능성 유형 도출에, {@code canceled}는 후보 제외에 쓴다. + * {@code targetFlagName}(제10조 제1항 제○호)은 효능이 아니라 보고 근거 구분이라 + * 유형 판정에는 쓰지 않고 화면에 근거로만 보여 준다. + */ +public record MfdsItem( + MfdsSource source, + String itemName, + String entpName, + String eeCode, + String eeName, + String spf, + String pa, + String targetFlagName, + String reportDate, + boolean canceled +) { + public boolean hasUvMeasurement() { + return notBlank(spf) || notBlank(pa); + } + + private static boolean notBlank(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/MfdsSource.java b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsSource.java new file mode 100644 index 0000000..daaed85 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsSource.java @@ -0,0 +1,20 @@ +package com.seoulection.admin.product.functional.domain; + +/** + * 기능성화장품 등록 경로. 법적으로 동등하다 — 심사는 새 처방을 식약처가 심사한 것이고, + * 보고는 이미 고시된 기준에 맞아 신고만 한 것이다. 어느 쪽에 있든 기능성화장품이다. + */ +public enum MfdsSource { + REPORT("보고"), + EXAMINATION("심사"); + + private final String displayName; + + MfdsSource(String displayName) { + this.displayName = displayName; + } + + public String displayName() { + return displayName; + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/domain/ScreeningOutcome.java b/src/main/java/com/seoulection/admin/product/functional/domain/ScreeningOutcome.java new file mode 100644 index 0000000..6fbaaf9 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/ScreeningOutcome.java @@ -0,0 +1,32 @@ +package com.seoulection.admin.product.functional.domain; + +/** + * 자동 판정의 결말. 이 값이 "사람이 봐야 하는가"를 정한다. + * + *

{@link #FAILED}를 {@link #NOT_MATCHED}와 나눠 두는 이유: API 오류로 못 본 것과 조회해 + * 봤는데 없는 것은 전혀 다른 사실이다. 실패를 "기능성 아님"으로 접으면 규제 정보가 조용히 + * 틀어진다. + */ +public enum ScreeningOutcome { + + AUTO_CONFIRMED("자동 확정", "success", false), + AUTO_NONE("자동 확정(기능성 아님)", "success", false), + NEEDS_REVIEW("확인 필요", "warning", true), + NOT_MATCHED("검색 결과 없음", "warning", true), + FAILED("조회 실패", "danger", true); + + private final String displayName; + private final String tone; + private final boolean requiresAdmin; + + ScreeningOutcome(String displayName, String tone, boolean requiresAdmin) { + this.displayName = displayName; + this.tone = tone; + this.requiresAdmin = requiresAdmin; + } + + public String displayName() { return displayName; } + public String tone() { return tone; } + public boolean requiresAdmin() { return requiresAdmin; } + public boolean decided() { return this == AUTO_CONFIRMED || this == AUTO_NONE; } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningConfiguration.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningConfiguration.java new file mode 100644 index 0000000..b9f21da --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningConfiguration.java @@ -0,0 +1,10 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.functional.application.FunctionalScreeningProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(FunctionalScreeningProperties.class) +public class FunctionalScreeningConfiguration { +} diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java new file mode 100644 index 0000000..c07b6a2 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java @@ -0,0 +1,132 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.functional.domain.ClaimReading; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.MfdsCandidate; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.MfdsSource; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.Instant; +import java.util.List; + +/** + * {@code functional_screenings} 한 건. _id가 곧 product_id다 — 제품당 최신 판정 하나만 둔다. + * + *

판정 이력을 쌓지 않는 이유: 되짚어야 하는 건 "지금 이 제품이 왜 이렇게 결정됐나"이지 + * 판정이 몇 번 돌았는지가 아니다. 규칙이 바뀐 재판정은 {@code engineVersion}으로 구분한다. + */ +@Document(collection = "functional_screenings") +public class FunctionalScreeningDocument { + + @Id + private String id; + + private ScreeningOutcome outcome; + private List claims; + private List candidates; + + @Field("selected_index") + private int selectedIndex; + + private String confidence; + private String reason; + + @Field("brand_registry_count") + private long brandRegistryCount; + + @Field("decided_by") + private String decidedBy; + + @Field("engine_version") + private String engineVersion; + + @Field("screened_at") + private Instant screenedAt; + + protected FunctionalScreeningDocument() { + } + + public static FunctionalScreeningDocument fromDomain(FunctionalScreening screening) { + FunctionalScreeningDocument document = new FunctionalScreeningDocument(); + document.id = screening.productId(); + document.outcome = screening.outcome(); + document.claims = screening.claims(); + document.candidates = screening.candidates().stream().map(Candidate::fromDomain).toList(); + document.selectedIndex = screening.selectedIndex(); + document.confidence = screening.confidence(); + document.reason = screening.reason(); + document.brandRegistryCount = screening.brandRegistryCount(); + document.decidedBy = screening.decidedBy(); + document.engineVersion = screening.engineVersion(); + document.screenedAt = screening.screenedAt(); + return document; + } + + public FunctionalScreening toDomain() { + return new FunctionalScreening(id, outcome, + claims == null ? List.of() : claims, + candidates == null ? List.of() : candidates.stream().map(Candidate::toDomain).toList(), + selectedIndex, confidence, reason, brandRegistryCount, decidedBy, engineVersion, screenedAt); + } + + /** 후보는 값 그대로 박아 둔다 — 나중에 화면을 열 때 안전나라를 다시 부르지 않기 위해서다. */ + public static class Candidate { + private String source; + @Field("item_name") + private String itemName; + @Field("entp_name") + private String entpName; + @Field("ee_name") + private String eeName; + private String spf; + private String pa; + @Field("target_flag_name") + private String targetFlagName; + @Field("report_date") + private String reportDate; + private double score; + private double coverage; + private List claims; + @Field("numeric_match") + private boolean numericMatch; + @Field("brand_match") + private boolean brandMatch; + @Field("out_of_scope") + private boolean outOfScope; + private boolean derivable; + + static Candidate fromDomain(MfdsCandidate candidate) { + MfdsItem item = candidate.item(); + Candidate row = new Candidate(); + row.source = item.source().name(); + row.itemName = item.itemName(); + row.entpName = item.entpName(); + row.eeName = item.eeName(); + row.spf = item.spf(); + row.pa = item.pa(); + row.targetFlagName = item.targetFlagName(); + row.reportDate = item.reportDate(); + row.score = candidate.score(); + row.coverage = candidate.coverage(); + row.claims = candidate.claims().categories(); + row.numericMatch = candidate.numericMatch(); + row.brandMatch = candidate.brandMatch(); + row.outOfScope = candidate.claims().outOfScope(); + row.derivable = candidate.claims().derivable(); + return row; + } + + MfdsCandidate toDomain() { + MfdsItem item = new MfdsItem(MfdsSource.valueOf(source), itemName, entpName, null, eeName, + spf, pa, targetFlagName, reportDate, false); + return new MfdsCandidate(item, score, coverage, + new ClaimReading(claims == null ? List.of() : claims, outOfScope, derivable), + numericMatch, brandMatch); + } + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningMongoRepository.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningMongoRepository.java new file mode 100644 index 0000000..f154cef --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningMongoRepository.java @@ -0,0 +1,29 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.functional.application.port.FunctionalScreeningRepository; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class FunctionalScreeningMongoRepository implements FunctionalScreeningRepository { + + private final MongoTemplate mongoTemplate; + + public FunctionalScreeningMongoRepository(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public void save(FunctionalScreening screening) { + mongoTemplate.save(FunctionalScreeningDocument.fromDomain(screening)); + } + + @Override + public Optional findByProductId(String productId) { + return Optional.ofNullable(mongoTemplate.findById(productId, FunctionalScreeningDocument.class)) + .map(FunctionalScreeningDocument::toDomain); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java new file mode 100644 index 0000000..4c773b1 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java @@ -0,0 +1,237 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.functional.application.FunctionalScreeningProperties; +import com.seoulection.admin.product.functional.application.port.CandidateVerdict; +import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; +import com.seoulection.admin.product.functional.application.port.ScreeningTarget; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; + +/** + * 이름 문제를 Gemini로 푸는 구현. {@code admin.functional-screening.llm.enabled=true}일 때 뜬다. + * + *

이 클래스는 기능성을 판단하지 않는다. 프롬프트 어디에도 "이 제품이 미백인가"를 묻는 + * 곳이 없고, 기능성 유형은 오직 안전나라 응답에서 나온다({@code FunctionalClaims}). 모델에게 + * 맡기는 건 이름 세 가지다 — 브랜드 한글 표기, 등록명 후보, "이 후보가 같은 제품인가". + * 규제 정보를 모델이 지어내지 못하게 하는 경계다. + * + *

SDK 대신 REST를 직접 부르는 이유: 이 프로젝트의 외부 연동이 전부 {@code RestClient}이고 + * (MFDS·V2 product-service·알림), 호출이 셋뿐이라 SDK 의존성을 더할 이유가 없다. + * + *

실패하면 예외를 던지지 않고 빈 결과를 돌려준다. LLM이 죽었다고 자동화 전체가 멈추면 안 되고, + * 빈 결과는 곧 "사람이 본다"로 이어지므로 안전한 방향의 실패다. + */ +@Component +@ConditionalOnProperty(name = "admin.functional-screening.llm.enabled", havingValue = "true") +public class GeminiProductNameResolver implements ProductNameResolverPort { + + private static final Logger log = LoggerFactory.getLogger(GeminiProductNameResolver.class); + + private final RestClient client; + private final ObjectMapper objectMapper; + private final FunctionalScreeningProperties.Llm config; + + public GeminiProductNameResolver(FunctionalScreeningProperties properties, ObjectMapper objectMapper) { + this.config = properties.getLlm(); + this.objectMapper = objectMapper; + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(config.getConnectTimeoutMs()); + factory.setReadTimeout(config.getReadTimeoutMs()); + this.client = RestClient.builder().baseUrl(config.getBaseUrl()).requestFactory(factory).build(); + } + + // ── 구조화 출력 스키마 ──────────────────────────────────────────────────── + + /** {@code index}가 Integer인 이유: 모델이 필드를 빠뜨려도 NPE 대신 -1로 흘려보내기 위함. */ + public record Judgement(Integer index, String confidence, String reason) { } + + public record BrandAliases(List koreanNames) { } + + public record RegistrationNames(List names) { } + + private static Map stringArraySchema(String field) { + return Map.of( + "type", "object", + "properties", Map.of(field, Map.of("type", "array", "items", Map.of("type", "string"))), + "required", List.of(field)); + } + + private static final Map JUDGEMENT_SCHEMA = Map.of( + "type", "object", + "properties", new LinkedHashMap<>(Map.of( + "index", Map.of("type", "integer"), + "confidence", Map.of("type", "string", "enum", List.of("HIGH", "MEDIUM", "LOW")), + "reason", Map.of("type", "string"))), + "required", List.of("index", "confidence", "reason")); + + // ── ① 브랜드 한글 표기 ──────────────────────────────────────────────────── + + @Override + public List koreanBrandAliases(String brand) { + String prompt = """ + 한국 화장품 브랜드가 식약처 기능성화장품 등록명에 어떤 한글로 적히는지 알려 주세요. + + 브랜드: %s + + 등록명(ITEM_NAME)은 "브랜드한글표기 + 제품명"이 공백 없이 붙은 형태입니다. + 예: 구달청귤비타씨잡티세럼 / 라운드랩자작나무수분선크림 / 조선미녀윤결수면팩 + + 표기가 갈릴 수 있으니 가능성이 높은 순으로 최대 4개 적어 주세요. 특히 이런 축으로: + - 음차 표기 그대로: Goodal → 구달, d'Alba → 달바, Torriden → 토리든 + - 국내 유통 브랜드명: Beauty of Joseon → 조선미녀 (뷰티오브조선이 아님) + - 음차가 갈리는 경우: numbuzin → 넘버즈인 / 넘버스인 + - 숫자·영문이 섞인 경우: SKIN1004 → 스킨1004 / 스킨천사 + + 규칙: + - 한글(숫자 포함)만 적습니다. 공백·기호는 넣지 마세요. + - 법인명은 절대 넣지 마세요. "(주)", "주식회사", "○○코리아" 같은 표기는 + 등록명에 거의 나오지 않습니다(전체 19만여 건 중 (주) 4건, 주식회사 5건). + 업체명은 조회가 아니라 응답의 ENTP_NAME으로 따로 확인합니다. + - 모르면 빈 배열을 주세요. 지어내지 마세요. + """.formatted(brand); + + return call(prompt, stringArraySchema("koreanNames"), BrandAliases.class) + .map(BrandAliases::koreanNames) + .orElse(List.of()); + } + + // ── ② 등록명 후보 ──────────────────────────────────────────────────────── + + @Override + public List registrationNameCandidates(ScreeningTarget target, String brandKo) { + String prompt = """ + 의약품안전나라 기능성화장품 등록명(ITEM_NAME) 후보를 만들어 주세요. + + 브랜드: %s (한글 표기: %s) + 제품명: %s + + 등록명은 유통명과 표기가 다릅니다. 공백·기호가 모두 빠진 한 덩어리이고, + 외래어는 한글로 음차됩니다. + 예: "구달 청귤 비타C 잡티 세럼" → 구달청귤비타씨잡티세럼 + "폴라초이스 10%% 나이아신아마이드 부스터" → 폴라초이스10퍼센트나이아신아마이드부스터 + "달바 워터풀 톤업 선크림 [SPF50+/PA++++]" → 달바워터풀톤업선크림 + + 규칙: + - 브랜드 한글 표기로 시작하는 형태를 반드시 포함하세요. + - SPF/PA 같은 대괄호 표기와 용량은 뺍니다. + - 표기가 갈릴 수 있는 부분(비타C/비타씨, 10%%/10퍼센트 등)은 각각 따로 넣어 최대 6개까지. + - 제품명이 길면 앞부분만 자른 형태도 하나 넣으세요 — 등록명 검색은 부분 일치라 + 뒤에 붙는 제형어가 달라도 걸립니다(예: 구달청귤비타씨). + - 제품명에 있는 숫자는 절대 바꾸지 마세요. + """.formatted(target.brand(), brandKo, target.displayName()); + + return call(prompt, stringArraySchema("names"), RegistrationNames.class) + .map(RegistrationNames::names) + .orElse(List.of()); + } + + // ── ③ 후보 판정 ────────────────────────────────────────────────────────── + + @Override + public CandidateVerdict judge(ScreeningTarget target, List candidates) { + if (candidates.isEmpty()) { + return CandidateVerdict.none("후보가 없습니다"); + } + String rows = IntStream.range(0, candidates.size()) + .mapToObj(i -> "%d. %s (업체: %s, 등록: %s)".formatted( + i, candidates.get(i).itemName(), candidates.get(i).entpName(), candidates.get(i).reportDate())) + .reduce((a, b) -> a + "\n" + b).orElse(""); + + String prompt = """ + 아래 제품과 같은 제품인 등록 건이 후보 중에 있는지 판정해 주세요. + + [우리 제품] + 브랜드: %s + 제품명: %s + 카테고리: %s + + [의약품안전나라 등록 후보] + %s + + 판정 기준: + - 제품명 안의 숫자(311, 77, 50 등)가 다르면 다른 제품입니다. 가장 흔한 오판입니다. + - 제형이 다르면(세럼 vs 크림 vs 마스크) 다른 제품입니다. + - 음차 표기 차이(비타C/비타씨, 10%%/10퍼센트)나 중간에 끼는 단어, 뒤에 붙는 (보) 같은 + 꼬리표는 같은 제품일 수 있습니다. + - 확신이 없으면 confidence를 LOW나 MEDIUM으로 주세요. 억지로 고르지 마세요. + + index: 같은 제품인 후보 번호. 없으면 -1. + confidence: HIGH | MEDIUM | LOW + reason: 한국어 한 문장. + """.formatted(target.brand(), target.displayName(), target.category(), rows); + + return call(prompt, JUDGEMENT_SCHEMA, Judgement.class) + .map(judgement -> new CandidateVerdict( + judgement.index() == null ? -1 : judgement.index(), + judgement.confidence() == null ? "LOW" : judgement.confidence(), + judgement.reason() == null ? "" : judgement.reason())) + .orElseGet(() -> CandidateVerdict.none("판정 호출에 실패했습니다")); + } + + // ── 호출 ───────────────────────────────────────────────────────────────── + + /** + * Gemini Interactions API 호출. 응답 본문의 {@code steps[].content[]} 중 {@code type=text}인 + * 첫 조각이 우리가 요청한 JSON이다. + */ + private Optional call(String prompt, Map schema, Class type) { + if (config.getApiKey() == null || config.getApiKey().isBlank()) { + log.warn("GEMINI_API_KEY가 비어 있어 이름 해석을 건너뜁니다 — 규칙만으로 판정합니다."); + return Optional.empty(); + } + try { + Map body = Map.of( + "model", config.getModel(), + "input", prompt, + "response_format", Map.of( + "type", "text", + "mime_type", "application/json", + "schema", schema)); + + Map response = client.post() + .header("x-goog-api-key", config.getApiKey()) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(Map.class); + + return firstText(response).map(text -> objectMapper.readValue(text, type)); + } catch (RuntimeException e) { + // 여기서 던지면 제품 한 건이 FAILED가 된다. 빈 결과 → 사람 큐가 더 나은 실패다. + log.warn("Gemini 호출 실패 ({}): {}", type.getSimpleName(), e.toString()); + return Optional.empty(); + } + } + + private Optional firstText(Map response) { + if (response == null || !(response.get("steps") instanceof List steps)) { + return Optional.empty(); + } + List texts = new ArrayList<>(); + for (Object step : steps) { + if (step instanceof Map stepMap && stepMap.get("content") instanceof List parts) { + for (Object part : parts) { + if (part instanceof Map partMap && "text".equals(partMap.get("type")) + && partMap.get("text") instanceof String text && !text.isBlank()) { + texts.add(text); + } + } + } + } + return texts.stream().findFirst(); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/HeuristicProductNameResolver.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/HeuristicProductNameResolver.java new file mode 100644 index 0000000..94d4062 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/HeuristicProductNameResolver.java @@ -0,0 +1,75 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.functional.application.port.CandidateVerdict; +import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; +import com.seoulection.admin.product.functional.application.port.ScreeningTarget; +import com.seoulection.admin.product.functional.domain.ItemName; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.List; + +/** + * LLM 없이 도는 대체 구현. {@code admin.functional-screening.llm.enabled=false}(기본)일 때 뜬다. + * + *

여기까지가 규칙으로 갈 수 있는 한계다. 표기 변환은 몇 가지 흔한 패턴만 처리하고, + * 후보 판정은 하지 않는다 — 애매한 건 전부 사람에게 넘긴다. 규칙이 어설프게 확정하는 + * 것보다 큐에 남는 편이 낫다. + */ +@Component +@ConditionalOnProperty(name = "admin.functional-screening.llm.enabled", havingValue = "false", matchIfMissing = true) +public class HeuristicProductNameResolver implements ProductNameResolverPort { + + /** 한글이 섞여 있으면 그 자체가 등록명에 쓰이는 표기다. 영문뿐이면 음차를 만들 수 없다. */ + @Override + public List koreanBrandAliases(String brand) { + return containsHangul(brand) ? List.of(brand.trim()) : List.of(); + } + + @Override + public List registrationNameCandidates(ScreeningTarget target, String brandKo) { + LinkedHashSet candidates = new LinkedHashSet<>(); + // 정규화가 기호를 지워 버리므로 표기 변환이 먼저다 — normalize 후엔 "%"가 이미 없다. + String branded = ItemName.normalize(transliterate(target.brandedName(brandKo))); + candidates.add(branded); + candidates.add(ItemName.normalize(transliterate(target.displayName()))); + + // 등록명 검색은 부분 일치라 앞부분만 넣어도 걸린다("구달청귤비타" → 구달청귤비타씨잡티세럼). + // 뒤에 붙는 제형어(세럼·크림)나 표기 차이를 통째로 우회하는 가장 값싼 방법이다. + for (int length : new int[]{10, 8, 6}) { + if (branded.length() > length) { + candidates.add(branded.substring(0, length)); + } + } + candidates.removeIf(value -> value == null || value.length() < 2); + return List.copyOf(candidates); + } + + /** 규칙만으로는 "311 vs 131"을 가릴 수 없다 — 판정하지 않고 후보만 남긴다. */ + @Override + public CandidateVerdict judge(ScreeningTarget target, List candidates) { + return CandidateVerdict.none("LLM 판정이 꺼져 있어 후보만 제시합니다"); + } + + /** + * 등록명에서 반복적으로 관찰되는 표기 차이 몇 가지. 정규화 전에 돌려야 한다. + * + *

실측 사례: {@code 폴라초이스 10% 나이아신아마이드} → {@code 폴라초이스10퍼센트나이아신아마이드}, + * {@code 구달 청귤 비타C 잡티 세럼} → {@code 구달청귤비타씨잡티세럼}. + */ + private String transliterate(String value) { + if (value == null) { + return ""; + } + return value + .replaceAll("(?i)비타\\s*C", "비타씨") + .replaceAll("(?i)비타민\\s*C", "비타민씨") + .replace("%", "퍼센트"); + } + + private boolean containsHangul(String value) { + return value != null && value.chars().anyMatch(ch -> ch >= 0xAC00 && ch <= 0xD7A3); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java new file mode 100644 index 0000000..7115918 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java @@ -0,0 +1,134 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.functional.application.FunctionalScreeningProperties; +import com.seoulection.admin.product.functional.application.port.MfdsCatalogPort; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.MfdsSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 의약품안전나라(data.go.kr) 기능성화장품 조회 클라이언트. + * + *

URL을 문자열로 조립해 {@code URI.create}로 넘기는 이유: 서비스 키가 이미 URL 인코딩된 + * 상태로 발급되는데({@code ...%2FXm1Lv...%3D%3D}) {@code RestClient}의 uri 템플릿에 문자열로 + * 넣으면 {@code %2F}가 {@code %252F}로 한 번 더 인코딩돼 403 "등록되지 않은 서비스키"가 난다. + * 키는 받은 그대로 붙이고, 검색어만 직접 인코딩한다. + * + *

심사(1471057)와 보고(1471000)는 응답 스키마가 다르다. 심사 쪽엔 효능효과(EE_NAME)가 + * 없어서 기능성 유형을 도출할 수 없다 — 그래서 심사에서만 발견된 제품은 "기능성이긴 하다"까지만 + * 알 수 있고 유형은 사람이 고른다. + */ +@Component +public class MfdsCatalogClient implements MfdsCatalogPort { + + private static final Logger log = LoggerFactory.getLogger(MfdsCatalogClient.class); + + private final RestClient client; + private final FunctionalScreeningProperties.Mfds config; + + public MfdsCatalogClient(FunctionalScreeningProperties properties) { + this.config = properties.getMfds(); + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(config.getConnectTimeoutMs()); + factory.setReadTimeout(config.getReadTimeoutMs()); + this.client = RestClient.builder().requestFactory(factory).build(); + } + + @Override + public List searchByItemName(String term) { + if (term == null || term.isBlank()) { + return List.of(); + } + List items = new ArrayList<>(fetch(config.getReportUrl(), MfdsSource.REPORT, term, 1, 50)); + items.addAll(fetch(config.getExamUrl(), MfdsSource.EXAMINATION, term, 1, 50)); + return items; + } + + @Override + public List searchBrand(String brandKo) { + if (brandKo == null || brandKo.isBlank()) { + return List.of(); + } + List items = new ArrayList<>(); + for (int page = 1; page <= config.getMaxBrandPages(); page++) { + List batch = fetch(config.getReportUrl(), MfdsSource.REPORT, brandKo, page, config.getPageSize()); + items.addAll(batch); + if (batch.size() < config.getPageSize()) { + break; // 마지막 페이지다. + } + } + items.addAll(fetch(config.getExamUrl(), MfdsSource.EXAMINATION, brandKo, 1, config.getPageSize())); + return items; + } + + @SuppressWarnings("unchecked") + private List fetch(String baseUrl, MfdsSource source, String itemName, int page, int rows) { + if (config.getServiceKey() == null || config.getServiceKey().isBlank()) { + throw new IllegalStateException("안전나라 서비스 키가 설정되지 않았습니다(admin.functional-screening.mfds.service-key)"); + } + String url = baseUrl + + "?serviceKey=" + config.getServiceKey() + + "&type=json&pageNo=" + page + "&numOfRows=" + rows + + "&item_name=" + URLEncoder.encode(itemName, StandardCharsets.UTF_8); + + // ⚠️ uri(String)을 쓰면 안 된다. RestClient가 그걸 URI 템플릿으로 보고 한 번 더 인코딩해서 + // 이미 인코딩된 서비스 키의 %2F가 %252F가 되고 403 "등록되지 않은 서비스키"로 튕긴다. + // URI.create로 넘겨 조립한 문자열을 그대로 쓰게 한다. + Map response = client.get().uri(URI.create(url)).retrieve().body(Map.class); + Map header = asMap(response == null ? null : response.get("header")); + String resultCode = header == null ? null : String.valueOf(header.get("resultCode")); + if (resultCode != null && !"00".equals(resultCode)) { + // 키 오류·쿼터 초과는 200 + 에러코드로 온다. 조용히 빈 목록으로 넘기면 "기능성 아님"이 된다. + throw new IllegalStateException("안전나라 응답 오류 " + resultCode + ": " + header.get("resultMsg")); + } + Map body = asMap(response == null ? null : response.get("body")); + Object rawItems = body == null ? null : body.get("items"); + if (!(rawItems instanceof List list)) { + return List.of(); + } + List items = new ArrayList<>(list.size()); + for (Object element : list) { + Map row = asMap(element); + if (row != null) { + items.add(toItem(source, row)); + } + } + log.debug("안전나라 조회 source={} term={} page={} → {}건", source, itemName, page, items.size()); + return items; + } + + private MfdsItem toItem(MfdsSource source, Map row) { + return new MfdsItem( + source, + text(row.get("ITEM_NAME")), + text(row.get("ENTP_NAME")), + text(row.get("EE_CODE")), + text(row.get("EE_NAME")), + text(row.get("SPF")), + text(row.get("PA")), + text(row.get("COSMETIC_TARGET_FLAG_NAME")), + // 심사는 허가일, 보고는 보고일. 화면에서는 "등록일" 한 칸으로 보여 준다. + text(row.get("REPORT_DATE") != null ? row.get("REPORT_DATE") : row.get("ITEM_PERMIT_DATE")), + "Y".equalsIgnoreCase(text(row.get("CANCEL_APPROVAL_YN")))); + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + return value instanceof Map ? (Map) value : null; + } + + private String text(Object value) { + return value == null ? null : String.valueOf(value); + } +} diff --git a/src/main/java/com/seoulection/admin/product/functional/presentation/FunctionalScreeningController.java b/src/main/java/com/seoulection/admin/product/functional/presentation/FunctionalScreeningController.java new file mode 100644 index 0000000..49a1c0e --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/presentation/FunctionalScreeningController.java @@ -0,0 +1,53 @@ +package com.seoulection.admin.product.functional.presentation; + +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 자동 조회를 사람이 직접 돌리는 자리. 두 가지뿐이다 — 이 제품 다시 조회, 큐 한 번에 조회. + * + *

여기서 기능성을 저장하지는 않는다. 저장은 기존 {@code /workflow/functions} 폼이 계속 + * 맡는다 — 자동 조회는 사람이 고를 재료를 채워 주는 일이고, 규제 정보를 확정하는 + * 마지막 클릭은 사람 몫으로 남긴다. + */ +@Controller +public class FunctionalScreeningController { + + /** 한 번에 훑을 상한. 안전나라가 제품당 여러 번 불려서 무제한으로 돌리면 쿼터가 먼저 나간다. */ + private static final int DEFAULT_BATCH_LIMIT = 50; + + private final FunctionalScreeningService service; + + public FunctionalScreeningController(FunctionalScreeningService service) { + this.service = service; + } + + @PostMapping("/admin/products/{id}/functional-screening") + public String rescreen(@PathVariable String id, RedirectAttributes redirectAttributes) { + FunctionalScreening screening = service.screen(id); + redirectAttributes.addFlashAttribute("successMessage", + "자동 조회를 다시 실행했습니다 — " + screening.outcome().displayName() + ": " + screening.reason()); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + + @PostMapping("/admin/products/functional-screening") + public String rescreenQueue(@RequestParam(required = false) Integer limit, + RedirectAttributes redirectAttributes) { + Map summary = service.screenQueue(limit == null ? DEFAULT_BATCH_LIMIT : limit); + String detail = summary.isEmpty() ? "처리할 제품이 없습니다" + : summary.entrySet().stream() + .map(entry -> entry.getKey().displayName() + " " + entry.getValue() + "건") + .collect(Collectors.joining(", ")); + redirectAttributes.addFlashAttribute("successMessage", "기능성 자동 조회 완료 — " + detail); + return "redirect:/admin/products?stage=functional-review"; + } +} diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java b/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java index 897620d..b3b0520 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java @@ -25,6 +25,8 @@ public class ProductDocument { private String asin; private String name; + @Field("name_kr") + private String nameKo; private String brand; private String category; private String description; @@ -72,6 +74,7 @@ private ProductDocument(Product product) { this.id = product.id(); this.asin = product.asin(); this.name = product.name(); + this.nameKo = product.nameKo(); this.brand = product.brand(); this.category = product.category(); this.description = product.description(); @@ -98,6 +101,7 @@ public Product toDomain() { .id(id) .asin(asin) .name(name) + .nameKo(nameKo) .brand(brand) .category(category) .description(description) diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java new file mode 100644 index 0000000..484a05b --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java @@ -0,0 +1,109 @@ +package com.seoulection.admin.product.infrastructure.repository; + +import com.seoulection.admin.product.application.dto.ProductIngredientProperty; +import com.seoulection.admin.product.application.dto.ProductIngredientResult; +import com.seoulection.admin.product.application.dto.PropertyDefinitionResult; +import com.seoulection.admin.product.application.port.ProductIngredientPort; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Repository; +import org.springframework.web.client.RestClient; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * seoulection-server-V2 의 product-service 어드민 API 를 부르는 어댑터. + * + *

{@code admin.product-ingredient.source=api} 일 때만 뜬다. 기본값은 여전히 + * {@link JdbcProductIngredientAdapter} 다 — 전환은 설정 한 줄이고 되돌리기도 같다. + * + *

🔴 서비스 키가 비어 있으면 V2 가 403 을 준다. 그런데 그 실패는 기동 시점이 아니라 + * 화면을 눌렀을 때 드러난다. api 로 전환할 때 {@code admin.product-service.service-key} 를 + * 반드시 함께 넣을 것. + */ +@Repository +@ConditionalOnProperty(name = "admin.product-ingredient.source", havingValue = "api") +public class ApiProductIngredientAdapter implements ProductIngredientPort { + + private final RestClient client; + private final String serviceKey; + + public ApiProductIngredientAdapter( + RestClient.Builder builder, + @Value("${admin.product-service.base-url:http://product-service:8080}") String baseUrl, + @Value("${admin.product-service.service-key:}") String serviceKey) { + this.serviceKey = serviceKey; + // Builder 를 주입받는 이유는 테스트다 — MockRestServiceServer 가 이 빌더에 붙어야 + // 실제 HTTP 없이 응답 매핑을 검증할 수 있다. 타임아웃은 AdminHttpClientConfig 가 건다. + this.client = builder.baseUrl(baseUrl).build(); + } + + @Override + public void replace(String productId, List rawNames, String source) { + if (productId == null) return; + client.put().uri("/internal/admin/v1/products/{id}/ingredients", productId) + .header("X-Service-Key", serviceKey) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("ingredients", rawNames == null ? List.of() : rawNames, + "source", source == null ? "ADMIN" : source)) + .retrieve().toBodilessEntity(); + } + + @Override + public List findByProductId(String productId) { + List rows = client.get() + .uri("/internal/admin/v1/products/{id}/ingredients", productId) + .header("X-Service-Key", serviceKey) + .retrieve().body(new ParameterizedTypeReference>() {}); + return rows == null ? List.of() : rows.stream().map(IngredientRow::toResult).toList(); + } + + @Override + public void review(String productId, long rowId, String ingredientId, BigDecimal min, BigDecimal max, + String unit, String notes, List properties) { + client.patch().uri("/internal/admin/v1/products/{id}/ingredients/{rowId}", productId, rowId) + .header("X-Service-Key", serviceKey) + .contentType(MediaType.APPLICATION_JSON) + .body(new ReviewBody(ingredientId, min, max, unit, notes, properties)) + .retrieve().toBodilessEntity(); + } + + /** + * 특성 정의는 아직 V2 에 API 가 없다. + * + *

빈 목록을 돌려주면 화면에 특성 입력 칸이 하나도 안 뜬다 — "고장" 이 아니라 "입력할 게 + * 없음" 으로 보여서 원인을 찾기 어렵다. V2 에 정의 API 가 생기기 전까지는 api 모드로 + * 전환하지 말 것. 여기서 명시적으로 실패시켜 그 사실을 드러낸다. + */ + @Override + public List propertyDefinitions() { + throw new UnsupportedOperationException( + "특성 정의 API가 V2에 아직 없다. admin.product-ingredient.source=jdbc 로 두거나 " + + "product-service에 GET /internal/admin/v1/property-definitions 를 먼저 추가할 것."); + } + + private record ReviewBody(String ingredientId, BigDecimal concentrationMin, BigDecimal concentrationMax, + String unit, String notes, List properties) {} + + /** + * V2 응답 모양. 어드민 DTO 와 필드 이름이 갈리므로 따로 둔다. + * + *

⚠️ 필드 이름이 V2 응답과 하나라도 어긋나면 조용히 null 이 되고 화면에는 빈 칸으로만 + * 보인다. {@code ApiProductIngredientAdapterTest} 가 그 어긋남을 잡는다. + */ + private record IngredientRow(long id, String ingredientId, String rawName, Integer order, + BigDecimal concentrationMin, BigDecimal concentrationMax, String unit, + String notes, String source, String inciName, String displayNameKo, + List properties) { + + ProductIngredientResult toResult() { + return new ProductIngredientResult(id, ingredientId, rawName, order == null ? 0 : order, + concentrationMin, concentrationMax, unit, notes, inciName, displayNameKo, + properties == null ? List.of() : properties); + } + } +} diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java new file mode 100644 index 0000000..473a523 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java @@ -0,0 +1,183 @@ +package com.seoulection.admin.product.infrastructure.repository; + +import com.seoulection.admin.product.application.dto.ProductIngredientProperty; +import com.seoulection.admin.product.application.dto.ProductIngredientResult; +import com.seoulection.admin.product.application.dto.PropertyDefinitionResult; +import com.seoulection.admin.product.application.port.ProductIngredientPort; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * PostgreSQL 직접 접근 어댑터 — 지금의 기본 구현. + * + *

제품은 Mongo 에, 제품-성분 연결은 PostgreSQL 에 있다. {@code product_id} 는 Mongo 의 문자열 ID다. + * 그래서 {@code product_ingredient.product_id} 에는 외래키를 걸 수 없다 — + * 검수 대상 제품은 아직 {@code products_catalog} 에 없다. + */ +@Repository +@ConditionalOnProperty(name = "admin.product-ingredient.source", havingValue = "jdbc", matchIfMissing = true) +public class JdbcProductIngredientAdapter implements ProductIngredientPort { + + private final JdbcTemplate jdbc; + + public JdbcProductIngredientAdapter(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + /** + * 전성분 목록을 이 제품의 성분 행에 반영한다. + * + *

⚠️ 전부 지우고 다시 넣지 않는다. 그렇게 하면 전성분을 다시 저장하는 순간 함량과 + * 특성이 통째로 사라진다(특성은 product_ingredient 를 ON DELETE CASCADE 로 따라간다). + * 오타 하나 고치려고 전성분을 다시 저장했다가 몇 시간치 보완 입력이 날아가는 일이라, + * 살아남는 성분은 행을 그대로 두고 순서만 고친다. + * + *

순서를 한 번 음수로 미는 이유: {@code unique (product_id, inci_order, raw_name)} 때문에 + * 성분 순서가 바뀌면 중간에 같은 순번이 겹치는 순간이 생긴다. 음수로 피신시켰다가 확정한다. + */ + @Override + @Transactional + public void replace(String productId, List rawNames, String source) { + if (productId == null) return; + String origin = source == null ? "ADMIN" : source; + List names = rawNames == null ? List.of() : rawNames.stream() + .filter(java.util.Objects::nonNull).map(String::trim) + .filter(name -> !name.isBlank()).distinct().toList(); + + if (names.isEmpty()) { + jdbc.update("delete from product_ingredient where product_id = ?", productId); + return; + } + + // 1) 목록에서 빠진 성분만 지운다. 남은 성분의 함량·특성은 건드리지 않는다. + String placeholders = String.join(",", java.util.Collections.nCopies(names.size(), "?")); + Object[] deleteArgs = java.util.stream.Stream.concat( + java.util.stream.Stream.of((Object) productId), names.stream()).toArray(); + jdbc.update("delete from product_ingredient where product_id = ? and raw_name not in (" + + placeholders + ")", deleteArgs); + + // 2) 순번 충돌 회피. + jdbc.update("update product_ingredient set inci_order = -inci_order " + + "where product_id = ? and inci_order > 0", productId); + + // 3) 살아남은 행은 순서만, 새 성분은 삽입. + for (int i = 0; i < names.size(); i++) { + String rawName = names.get(i); + String matched = matchIngredient(rawName); + int updated = jdbc.update("update product_ingredient set inci_order = ?, source = ?, " + // 사람이 손으로 연결해 둔 값은 덮지 않는다. 비어 있을 때만 채운다 — + // 사전에 성분이 늦게 등록돼도 다음 저장에서 자동으로 이어진다. + + "ingredient_id = coalesce(ingredient_id, ?) " + + "where product_id = ? and raw_name = ?", + i + 1, origin, matched, productId, rawName); + if (updated == 0) { + jdbc.update("insert into product_ingredient(product_id, ingredient_id, raw_name, inci_order, source)" + + " values (?, ?, ?, ?, ?)", productId, matched, rawName, i + 1, origin); + } + } + } + + /** + * 원문 → 성분 사전 id. + * + *

한글명({@code display_name_ko})까지 보는 이유: 국내 제품의 전성분은 한글로 적힌다 + * ("나이아신아마이드"). INCI 명과 별칭만 보면 한글 전성분은 한 건도 연결되지 않아 + * 성분별 보완 화면이 늘 비게 된다. + * + *

공백을 지우고 비교한다 — "히알루론산 나트륨"과 "히알루론산나트륨"은 같은 성분이다. + */ + private String matchIngredient(String rawName) { + return jdbc.query(""" + select i.id from ingredient i + where lower(replace(i.inci_name, ' ', '')) = lower(replace(?, ' ', '')) + or lower(replace(i.display_name_ko, ' ', '')) = lower(replace(?, ' ', '')) + union + select ia.ingredient_id from ingredient_alias ia + where lower(replace(ia.alias, ' ', '')) = lower(replace(?, ' ', '')) + limit 1 + """, + ps -> { ps.setString(1, rawName); ps.setString(2, rawName); ps.setString(3, rawName); }, + rs -> rs.next() ? rs.getString(1) : null); + } + + @Override + public List findByProductId(String productId) { + // 특성은 행마다 개수가 달라 한 번에 모아 두고 붙인다(성분 30개에 쿼리 30번을 더 쏘지 않는다). + Map> properties = new LinkedHashMap<>(); + jdbc.query(""" + select p.product_ingredient_id, p.property_key, d.display_name_ko, d.value_type, + p.value_text, p.value_min, p.value_max, p.value_unit, p.notes + from product_ingredient_property p + join product_ingredient pi on pi.id = p.product_ingredient_id + left join property_definition d on d.property_key = p.property_key + where pi.product_id = ? order by p.property_key + """, rs -> { + properties.computeIfAbsent(rs.getLong("product_ingredient_id"), k -> new ArrayList<>()) + .add(new ProductIngredientProperty(rs.getString("property_key"), rs.getString("display_name_ko"), + rs.getString("value_type"), rs.getString("value_text"), + rs.getBigDecimal("value_min"), rs.getBigDecimal("value_max"), + rs.getString("value_unit"), rs.getString("notes"))); + }, productId); + + return jdbc.query(""" + select pi.id, pi.ingredient_id, pi.raw_name, pi.inci_order, + pi.concentration_min, pi.concentration_max, pi.concentration_unit, + pi.notes, i.inci_name, i.display_name_ko + from product_ingredient pi + left join ingredient i on i.id = pi.ingredient_id + where pi.product_id = ? order by pi.inci_order + """, (rs, n) -> new ProductIngredientResult(rs.getLong("id"), rs.getString("ingredient_id"), + rs.getString("raw_name"), rs.getInt("inci_order"), rs.getBigDecimal("concentration_min"), + rs.getBigDecimal("concentration_max"), rs.getString("concentration_unit"), + rs.getString("notes"), rs.getString("inci_name"), rs.getString("display_name_ko"), + properties.getOrDefault(rs.getLong("id"), List.of())), productId); + } + + @Override + @Transactional + public void review(String productId, long rowId, String ingredientId, BigDecimal min, BigDecimal max, + String unit, String notes, List properties) { + // ⚠️ ingredient_id 는 건드리지 않는다. 사전 연결은 전성분 저장 때 matchIngredient 가 + // 정하는 것이고, 화면에는 손으로 고칠 칸이 없다(연결된 성분만 이 폼이 열린다). + // 예전처럼 폼 값으로 덮으면, 값이 비어 온 순간 연결이 끊기고 그 행은 편집 목록에서 + // 사라져 버린다 — 방금 저장한 성분이 화면에서 없어지는 셈이다. + int changed = jdbc.update("update product_ingredient set concentration_min=?, concentration_max=?," + + " concentration_unit=?, notes=? where id=? and product_id=?", + min, max, blank(unit), blank(notes), rowId, productId); + if (changed == 0) throw new IllegalArgumentException("제품 성분 행을 찾을 수 없습니다."); + + if (properties == null) return; // 미지정 = 특성은 건드리지 않는다 + jdbc.update("delete from product_ingredient_property where product_ingredient_id=?", rowId); + for (ProductIngredientProperty property : properties) { + String key = blank(property.propertyKey()); + if (key == null || isEmpty(property)) continue; // 빈 칸은 저장하지 않는다 = 지운 것과 같다 + jdbc.update(""" + insert into product_ingredient_property(product_ingredient_id, property_key, value_text, + value_min, value_max, value_unit, source, notes) + values (?,?,?,?,?,?, 'ADMIN', ?) + """, rowId, key, blank(property.valueText()), property.valueMin(), property.valueMax(), + blank(property.valueUnit()), blank(property.notes())); + } + } + + @Override + public List propertyDefinitions() { + return jdbc.query("select property_key, display_name_ko, value_type, value_unit, description" + + " from property_definition order by display_name_ko", + (rs, n) -> new PropertyDefinitionResult(rs.getString("property_key"), rs.getString("display_name_ko"), + rs.getString("value_type"), rs.getString("value_unit"), rs.getString("description"))); + } + + private boolean isEmpty(ProductIngredientProperty property) { + return (property.valueText() == null || property.valueText().isBlank()) + && property.valueMin() == null && property.valueMax() == null; + } + + private String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductIngredientPostgresRepository.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductIngredientPostgresRepository.java deleted file mode 100644 index 4023b97..0000000 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductIngredientPostgresRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.seoulection.admin.product.infrastructure.repository; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -/** 제품은 Mongo에, 제품-성분 연결은 PostgreSQL에 둔다. product_id는 Mongo의 문자열 ID다. */ -@Repository -public class ProductIngredientPostgresRepository { - private final JdbcTemplate jdbc; - - public ProductIngredientPostgresRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } - - @Transactional - public void replace(String productId, List rawNames, String source) { - if (productId == null) return; - jdbc.update("delete from product_ingredient where product_id = ?", productId); - if (rawNames == null) return; - for (int i = 0; i < rawNames.size(); i++) { - String rawName = rawNames.get(i) == null ? "" : rawNames.get(i).trim(); - if (rawName.isBlank()) continue; - String ingredientId = jdbc.query("select i.id from ingredient i where lower(i.inci_name) = lower(?) " + - "union select ia.ingredient_id from ingredient_alias ia where lower(ia.alias) = lower(?) limit 1", - ps -> { ps.setString(1, rawName); ps.setString(2, rawName); }, - rs -> rs.next() ? rs.getString(1) : null); - jdbc.update("insert into product_ingredient(product_id, ingredient_id, raw_name, inci_order, source) values (?, ?, ?, ?, ?)", - productId, ingredientId, rawName, i + 1, source == null ? "ADMIN" : source); - } - } -} diff --git a/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java b/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java index fd1d7fa..e943bab 100644 --- a/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java +++ b/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java @@ -1,6 +1,9 @@ package com.seoulection.admin.product.presentation.controller; import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; import com.seoulection.admin.product.domain.enums.ProductStage; import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.presentation.dto.ProductRegisterRequest; @@ -20,6 +23,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import com.seoulection.admin.product.application.dto.ProductIngredientProperty; +import java.util.ArrayList; +import java.math.BigDecimal; @Controller public class ProductController { @@ -28,10 +34,13 @@ public class ProductController { private static final int PAGE_SIZE = 25; private final ProductService service; + private final FunctionalScreeningService screeningService; private final ObjectMapper objectMapper; - public ProductController(ProductService service, ObjectMapper objectMapper) { + public ProductController(ProductService service, FunctionalScreeningService screeningService, + ObjectMapper objectMapper) { this.service = service; + this.screeningService = screeningService; this.objectMapper = objectMapper; } @@ -133,8 +142,15 @@ public String register( model.addAttribute("registerFormOpen", true); return "products"; } - service.register(request.getName(), request.getBrand(), request.getCategory(), - splitIngredients(request.getIngredientsText())); + List ingredients = splitIngredients(request.getIngredientsText()); + var created = service.register(request.getName(), request.getNameKo(), request.getBrand(), + request.getCategory(), ingredients); + // 성분을 함께 넣었으면 함량을 바로 채우게 성분 탭으로 보낸다. 함량은 성분 행이 + // 저장된 뒤에야 붙일 수 있어 등록 폼에서 미리 받을 수 없다. + if (!ingredients.isEmpty()) { + redirectAttributes.addFlashAttribute("successMessage", "제품을 등록했습니다. 이어서 함량을 입력하세요."); + return "redirect:/admin/products/" + created.id() + "/workflow?step=ingredients"; + } redirectAttributes.addFlashAttribute("successMessage", "제품을 등록했습니다."); return "redirect:/admin/products"; } @@ -150,10 +166,68 @@ private List splitIngredients(String text) { public String detail(@PathVariable String id, Model model) { var product = service.getProduct(id); model.addAttribute("product", product); + model.addAttribute("productIngredients", service.getProductIngredients(id)); + model.addAttribute("propertyDefinitions", service.propertyDefinitions()); model.addAttribute("inciapiRawJson", prettyJson(product.inciapiRawData())); return "product-detail"; } + @PostMapping("/admin/products/{id}/basic") + public String updateBasic(@PathVariable String id, @RequestParam String name, + @RequestParam(required=false) String nameKo, @RequestParam String brand, + @RequestParam String category, RedirectAttributes redirectAttributes) { + service.updateBasicInfo(id, name, nameKo, brand, category); + redirectAttributes.addFlashAttribute("successMessage", "제품 기본 정보를 저장했습니다."); + return "redirect:/admin/products/" + id; + } + + /** + * 성분 한 행 저장 — 함량과 이 제품에서의 특성. + * + *

특성은 폼에서 {@code propertyKey[]}, {@code propertyValueText[]} ... 처럼 같은 이름의 배열로 + * 온다. 순서가 곧 짝이므로 인덱스로 묶는다. 빈 칸은 저장하지 않는다 — 지운 것과 같게 다룬다. + */ + @PostMapping("/admin/products/{id}/ingredients/{rowId}") + public String reviewIngredient(@PathVariable String id, @PathVariable long rowId, + @RequestParam(required=false) String ingredientId, + @RequestParam(required=false) BigDecimal concentrationMin, + @RequestParam(required=false) BigDecimal concentrationMax, + @RequestParam(required=false) String unit, + @RequestParam(required=false) String notes, + @RequestParam(required=false) List propertyKey, + @RequestParam(required=false) List propertyValueText, + @RequestParam(required=false) List propertyValueMin, + @RequestParam(required=false) List propertyValueMax, + RedirectAttributes redirectAttributes) { + service.reviewProductIngredient(id, rowId, ingredientId, concentrationMin, concentrationMax, unit, notes, + toProperties(propertyKey, propertyValueText, propertyValueMin, propertyValueMax)); + redirectAttributes.addFlashAttribute("successMessage", "제품별 성분 정보를 저장했습니다."); + return "redirect:/admin/products/" + id; + } + + private List toProperties(List keys, List texts, + List mins, List maxs) { + if (keys == null) return List.of(); // 특성 칸이 아예 없는 폼 → 건드리지 않는다 + List properties = new ArrayList<>(); + for (int i = 0; i < keys.size(); i++) { + String key = keys.get(i); + if (key == null || key.isBlank()) continue; + properties.add(new ProductIngredientProperty(key, at(texts, i), + decimal(at(mins, i)), decimal(at(maxs, i)), null, null)); + } + return properties; + } + + private String at(List values, int index) { + return values == null || index >= values.size() ? null : values.get(index); + } + + /** 빈 칸과 "숫자가 아님"을 모두 null 로 본다 — 폼 하나 때문에 500을 내지 않는다. */ + private BigDecimal decimal(String value) { + if (value == null || value.isBlank()) return null; + try { return new BigDecimal(value.trim()); } catch (NumberFormatException e) { return null; } + } + private String prettyJson(Map raw) { if (raw == null || raw.isEmpty()) { return null; @@ -193,8 +267,115 @@ public String workflowIngredients(@PathVariable String id, @ModelAttribute Produ return "redirect:/admin/products/" + id + "/workflow"; } service.reviewIngredients(id, ingredients, ingredientNotFound); - redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다."); - return "redirect:/admin/products?stage=ingredient-review"; + // 성분을 찾지 못했으면 채울 함량이 없으니 큐로 돌아간다. 찾았으면 같은 탭에 남아 + // 방금 저장된 성분 목록에 함량을 채우게 한다 — 화면을 옮기면 맥락이 끊긴다. + if (ingredientNotFound) { + redirectAttributes.addFlashAttribute("successMessage", "성분을 찾지 못함으로 저장했습니다."); + return "redirect:/admin/products?stage=ingredient-review"; + } + redirectAttributes.addFlashAttribute("successMessage", + "전성분을 저장했습니다. 이어서 성분별 보완을 마치고 아래 '성분 보완 완료'를 누르세요."); + return "redirect:/admin/products/" + id + "/workflow?step=ingredients"; + } + + /** + * 1단계 완료 선언 — 성분별 보완을 마쳤다는 뜻이고, 여기서 상태가 INGREDIENTS_ADDED 가 된다. + * + *

함량을 하나도 안 채웠어도 누를 수 있다. 채울 값이 없는 제품이 실제로 있고, 그때 + * 완료를 막으면 제품이 성분 보완 큐에 영원히 남는다. + */ + @PostMapping("/admin/products/{id}/workflow/ingredients/complete") + public String completeIngredientReview(@PathVariable String id, RedirectAttributes redirectAttributes) { + try { + service.completeIngredientReview(id); + } catch (IllegalStateException e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + return "redirect:/admin/products/" + id + "/workflow?step=ingredients"; + } + redirectAttributes.addFlashAttribute("successMessage", "성분 보완을 마쳤습니다. 이어서 기능성을 확인하세요."); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + + /** + * 2단계 자동 — 한글 이름을 저장하고 곧바로 의약품안전나라를 조회해 기능성까지 기록한다. + * + *

트리거가 한글 이름인 이유: 안전나라 등록명(ITEM_NAME)은 전부 한글이고 브랜드 한글 + * 표기로 시작한다("구달청귤비타씨잡티세럼"). 영문 제품명만으로는 조회가 시작조차 안 되므로, + * 한글 이름이 채워지는 그 순간이 자동 조회가 가장 잘 듣는 시점이다. + * + *

확정되면 큐로 돌아가고, 못 찾으면 같은 화면에 남아 후보와 사유를 보여 준다 — + * 어드민이 손대는 건 그때뿐이다. + */ + @PostMapping("/admin/products/{id}/workflow/functional-screening") + public String workflowScreen(@PathVariable String id, @RequestParam(required = false) String nameKo, + RedirectAttributes redirectAttributes) { + if (nameKo == null || nameKo.isBlank()) { + redirectAttributes.addFlashAttribute("errorMessage", + "자동 조회는 한글 이름으로 검색합니다 — 한글 이름을 먼저 입력해 주세요."); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + var current = service.getProduct(id); + service.updateBasicInfo(id, current.name(), nameKo.trim(), current.brand(), current.category()); + + var screened = screeningService.screenAfterNameSaved(id); + if (screened.isEmpty()) { + redirectAttributes.addFlashAttribute("errorMessage", + "자동 조회가 꺼져 있습니다(admin.functional-screening.enabled). 아래에서 직접 입력해 주세요."); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + + FunctionalScreening screening = screened.get(); + if (screening.outcome().decided() && screeningService.appliesDecisions()) { + // 규제 정보가 조용히 저장되고 화면만 넘어가면 나중에 되짚을 실마리가 없다 — + // 무엇이 어떤 근거로 기록됐는지 문구로 남긴다. + redirectAttributes.addFlashAttribute("successMessage", + "한글 이름을 저장하고 기능성을 자동 확정했습니다 — " + describe(screening)); + return "redirect:/admin/products?stage=functional-review"; + } + if (screening.outcome().decided()) { + // 판정은 끝났지만 확정은 사람이 한다. 폼이 미리 채워진 채로 열리고, 어드민은 + // 근거를 보고 저장만 누르면 된다. + redirectAttributes.addFlashAttribute("successMessage", + "자동 조회 결과를 아래에 채워 두었습니다 — " + describe(screening) + + " 확인 후 저장을 눌러 확정해 주세요."); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + redirectAttributes.addFlashAttribute("errorMessage", + "자동 조회로 확정하지 못했습니다(" + screening.outcome().displayName() + "): " + + screening.reason() + " 아래에서 직접 확인해 주세요."); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + + /** + * 자동 판정 결과를 검수 폼에 미리 채운다. 어드민은 근거를 보고 저장만 누르면 된다. + * + *

⚠️ "기능성 아님"은 미리 고르지 않는다. 확인 없이 저장만 눌러도 식약처 기능성이 + * 아니라는 사실이 기록되는 게 이 폼에서 가장 비싼 실수이고, 자동 조회가 못 찾은 것과 + * 실제로 기능성이 아닌 것은 겉보기가 같다. 반대로 유형이 나온 경우는 안전나라 응답이라는 + * 근거가 있으므로 채워 둔다. + * + *

이미 검수를 마친 제품은 건드리지 않는다 — 사람이 정한 값을 자동 판정이 덮으면 안 된다. + */ + private void prefillFromScreening(ProductRegisterRequest request, + com.seoulection.admin.product.application.dto.ProductResult product, + FunctionalScreening screening) { + if (screening == null || product.status().functionalReviewDone() || screening.claims().isEmpty()) { + return; + } + request.setFunctionResult("CONFIRMED"); + request.setFunction(screening.claims().stream().map(Enum::name).toList()); + } + + /** 자동 확정 결과 문구. 유형이 비어 있으면 "기능성 아님"으로 확정된 것이다. */ + private String describe(FunctionalScreening screening) { + var selected = screening.selected(); + String evidence = selected == null ? "" : " / 근거: " + selected.item().itemName(); + if (screening.claims().isEmpty()) { + return "기능성 아님 (" + screening.reason() + ")"; + } + return screening.claims().stream() + .map(ProductFunctionalCategory::displayName) + .reduce((a, b) -> a + ", " + b).orElse("") + evidence; } /** 2단계 저장 — 식약처 기능성만. 저장 후 기능성 확인 큐로 돌아간다. */ @@ -206,6 +387,13 @@ public String workflowFunctions(@PathVariable String id, @ModelAttribute Product redirectAttributes.addFlashAttribute("errorMessage", "의약품안전나라 조회 결과를 선택해 주세요."); return "redirect:/admin/products/" + id + "/workflow"; } + // 한글 이름은 기능성과 같은 탭에서 받는다. 비워 두면 유지한다 — + // 지우려는 의도와 구분할 수 없어 덮어쓰지 않는다. + String nameKo = request.getNameKo(); + if (nameKo != null && !nameKo.isBlank()) { + var current = service.getProduct(id); + service.updateBasicInfo(id, current.name(), nameKo.trim(), current.brand(), current.category()); + } boolean confirmed = "CONFIRMED".equals(result); boolean hasFunction = !request.getFunction().isEmpty(); if (confirmed && !hasFunction) { @@ -214,10 +402,21 @@ public String workflowFunctions(@PathVariable String id, @ModelAttribute Product } // '기능성 아님'을 고르고 유형을 남겨 두면 모순이므로 유형을 버린다. service.reviewFunction(id, confirmed ? request.getFunction() : List.of()); + // 자동 판정이 남아 있다면 "사람이 정했다"로 덮는다 — 나중에 이 제품의 기능성이 + // 누구의 판단이었는지 되짚을 수 있어야 한다. + screeningService.markDecidedByAdmin(id); redirectAttributes.addFlashAttribute("successMessage", "기능성 검수 정보를 저장했습니다."); return "redirect:/admin/products?stage=functional-review"; } + /** + * 검수 탭. 기존 2탭 구조를 지킨다 — 성분 보완(성분+함량)과 기능성 확인(한글 이름+기능성). + * + *

탭을 더 쪼개지 않는 이유: 근거 자료가 다른 두 작업이라 나누는 것이지, 입력 항목마다 + * 나누면 저장 버튼만 늘고 어드민이 같은 제품을 네 번 열게 된다. + */ + private static final List WORKFLOW_STEPS = List.of("ingredients", "functional"); + /** * 검수 작업 화면. 어느 단계를 열지는 제품 상태가 정한다({@link ProductStatus#workflowStep()}). * step 파라미터는 이미 지나간 단계를 다시 여는 용도다 — 기능성 화면에서 "성분 수정"으로 @@ -241,14 +440,30 @@ public String workflowPage(@PathVariable String id, request.setIngredientsText(product.ingredients() == null ? "" : String.join(", ", product.ingredients())); model.addAttribute("product", product); model.addAttribute("request", request); - String resolved = "ingredients".equals(step) || "functional".equals(step) - ? step - : product.status().workflowStep(); + // 4단계 마법사: 성분 → 함량 → 한글 이름 → 기능성. + // step 이 없으면 제품 상태가 진입점을 정한다(status.workflowStep()). + // ⚠️ step == null 검사를 빼지 말 것. List.of() 는 불변 리스트라 contains(null) 이 + // false 가 아니라 NullPointerException 이다(step 파라미터는 대개 없다). + String resolved = step != null && WORKFLOW_STEPS.contains(step) + ? step : product.status().workflowStep(); if (resolved == null) { // 파이프라인이 굴리는 중이거나 이미 끝난 제품은 어드민이 할 일이 없다 — 상세로 보낸다. return "redirect:/admin/products/" + id; } model.addAttribute("workflowStep", resolved); + model.addAttribute("workflowSteps", WORKFLOW_STEPS); + model.addAttribute("workflowStepIndex", WORKFLOW_STEPS.indexOf(resolved)); + if ("ingredients".equals(resolved)) { + model.addAttribute("productIngredients", service.getProductIngredients(id)); + model.addAttribute("propertyDefinitions", service.propertyDefinitions()); + } + if ("functional".equals(resolved)) { + // 한글 이름이 이미 있으면 화면을 여는 것만으로 자동 조회가 한 번 돈다. 없으면 + // 조회할 근거가 없으니 아무것도 하지 않고 입력 칸만 보여 준다. + var screening = screeningService.findOrScreen(id).orElse(null); + model.addAttribute("screening", screening); + prefillFromScreening(request, product, screening); + } return "product-workflow"; } } diff --git a/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java b/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java index 6ca2110..1e40deb 100644 --- a/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java +++ b/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java @@ -22,6 +22,7 @@ public class ProductRegisterRequest { @NotBlank(message = "브랜드명을 입력해 주세요.") @Size(max = 100, message = "브랜드명은 100자 이하여야 합니다.") private String brand; + private String nameKo; @NotBlank(message = "카테고리를 입력해 주세요.") @Pattern( @@ -49,6 +50,8 @@ public class ProductRegisterRequest { public String getBrand() { return brand; } public void setBrand(String brand) { this.brand = brand; } + public String getNameKo() { return nameKo; } + public void setNameKo(String nameKo) { this.nameKo = nameKo; } public String getCategory() { return category; } public void setCategory(String category) { this.category = category; } diff --git a/src/main/java/com/seoulection/admin/survey/application/dto/SurveyEvidenceResult.java b/src/main/java/com/seoulection/admin/survey/application/dto/SurveyEvidenceResult.java new file mode 100644 index 0000000..dec608c --- /dev/null +++ b/src/main/java/com/seoulection/admin/survey/application/dto/SurveyEvidenceResult.java @@ -0,0 +1,28 @@ +package com.seoulection.admin.survey.application.dto; + +import com.seoulection.admin.survey.infrastructure.entity.SurveyQuestionEvidenceJpaEntity; + +/** 화면에 뿌릴 근거 한 줄. */ +public record SurveyEvidenceResult(Long id, String questionKey, String title, String rationale, + String url, String sourceType, Integer sortOrder) { + + public static SurveyEvidenceResult from(SurveyQuestionEvidenceJpaEntity entity) { + return new SurveyEvidenceResult(entity.getId(), entity.getQuestionKey(), entity.getTitle(), + entity.getRationale(), entity.getUrl(), entity.getSourceType(), entity.getSortOrder()); + } + + /** 링크 없는 내부 자료가 있다 — 화면이 빈 링크를 그리지 않게 판단을 여기서 한다. */ + public boolean hasUrl() { + return url != null && !url.isBlank(); + } + + public String sourceLabel() { + return switch (sourceType == null ? "" : sourceType) { + case "PAPER" -> "논문"; + case "GUIDELINE" -> "가이드라인"; + case "CLINICAL" -> "임상"; + case "INTERNAL" -> "내부 자료"; + default -> "기사·기타"; + }; + } +} diff --git a/src/main/java/com/seoulection/admin/survey/application/service/SurveyEvidenceService.java b/src/main/java/com/seoulection/admin/survey/application/service/SurveyEvidenceService.java new file mode 100644 index 0000000..f75dac7 --- /dev/null +++ b/src/main/java/com/seoulection/admin/survey/application/service/SurveyEvidenceService.java @@ -0,0 +1,85 @@ +package com.seoulection.admin.survey.application.service; + +import com.seoulection.admin.survey.application.dto.SurveyEvidenceResult; +import com.seoulection.admin.survey.infrastructure.entity.SurveyQuestionEvidenceJpaEntity; +import com.seoulection.admin.survey.infrastructure.repository.SurveyQuestionEvidenceJpaRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 설문 문항의 근거 관리. + * + *

설문이 "근거 기반" 이어야 한다는 요구에서 나왔다. 문항 하나에 근거 여러 개가 붙는다 — + * 논문 하나로 시작해도 나중에 가이드라인·임상 자료가 붙기 때문이다. + */ +@Service +public class SurveyEvidenceService { + + /** 마이그레이션의 CHECK 제약과 같은 집합. 여기서 먼저 걸러 DB 오류가 500 으로 새지 않게 한다. */ + private static final Set SOURCE_TYPES = Set.of("PAPER", "GUIDELINE", "CLINICAL", "ARTICLE", "INTERNAL"); + + private final SurveyQuestionEvidenceJpaRepository repository; + + public SurveyEvidenceService(SurveyQuestionEvidenceJpaRepository repository) { + this.repository = repository; + } + + /** 문항 키 → 근거 목록. 문항마다 쿼리를 쏘지 않으려고 한 번에 읽어 나눈다. */ + public Map> byQuestion() { + return repository.findAllByOrderByQuestionKeyAscSortOrderAscIdAsc().stream() + .map(SurveyEvidenceResult::from) + .collect(Collectors.groupingBy(SurveyEvidenceResult::questionKey, + LinkedHashMap::new, Collectors.toList())); + } + + @Transactional + public void add(String questionKey, String title, String rationale, String url, + String sourceType, Integer sortOrder) { + require(questionKey, "문항을 선택해 주세요."); + require(title, "근거 제목을 입력해 주세요."); + // 링크만 모으면 "왜 묻는지"는 여전히 사람 머릿속에 남는다. 요약을 필수로 받는 이유다. + require(rationale, "이 문항을 왜 묻는지 적어 주세요."); + repository.save(new SurveyQuestionEvidenceJpaEntity(questionKey.trim(), title.trim(), + rationale.trim(), blank(url), normalizeSource(sourceType), sortOrder)); + } + + @Transactional + public void update(Long id, String title, String rationale, String url, + String sourceType, Integer sortOrder) { + require(title, "근거 제목을 입력해 주세요."); + require(rationale, "이 문항을 왜 묻는지 적어 주세요."); + SurveyQuestionEvidenceJpaEntity entity = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("근거를 찾을 수 없습니다.")); + entity.update(title.trim(), rationale.trim(), blank(url), normalizeSource(sourceType), sortOrder); + } + + @Transactional + public void delete(Long id) { + repository.deleteById(id); + } + + /** + * 알 수 없는 종류는 거부하지 않고 ARTICLE 로 흘린다. + * + *

화면의 select 로만 들어오는 값이라 잘못된 값은 사실상 오지 않는다. 그런데 거부하면 + * 근거 입력 자체가 막히고, 종류는 신뢰도 표시용이라 틀려도 손실이 작다 — 근거를 못 남기는 + * 쪽이 더 나쁘다. + */ + private String normalizeSource(String value) { + if (value == null) return "ARTICLE"; + String upper = value.trim().toUpperCase(); + return SOURCE_TYPES.contains(upper) ? upper : "ARTICLE"; + } + + private void require(String value, String message) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(message); + } + + private String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionEvidenceJpaEntity.java b/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionEvidenceJpaEntity.java new file mode 100644 index 0000000..72d40fc --- /dev/null +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionEvidenceJpaEntity.java @@ -0,0 +1,71 @@ +package com.seoulection.admin.survey.infrastructure.entity; + +import jakarta.persistence.*; + +import java.time.Instant; + +/** + * {@code survey_question_evidence} 영속성 엔티티. 스키마 주인은 api-server 다(여기는 ddl-auto=none). + * + *

설문 문항의 근거 — "이 문항을 왜 묻는가". 문항 하나에 여러 근거가 붙는다(1:N). + */ +@Entity +@Table(name = "survey_question_evidence") +public class SurveyQuestionEvidenceJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "question_key", nullable = false, length = 32) + private String questionKey; + + @Column(nullable = false, length = 300) + private String title; + + /** 🔴 이 필드가 이 테이블의 목적이다. 링크를 열지 않고도 "왜 묻는지"를 알 수 있어야 한다. */ + @Column(nullable = false, columnDefinition = "text") + private String rationale; + + /** 내부 자료는 링크가 없을 수 있다. */ + @Column(length = 1000) + private String url; + + @Column(name = "source_type", nullable = false, length = 30) + private String sourceType; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", insertable = false, updatable = false) + private Instant createdAt; + + protected SurveyQuestionEvidenceJpaEntity() { } + + public SurveyQuestionEvidenceJpaEntity(String questionKey, String title, String rationale, + String url, String sourceType, Integer sortOrder) { + this.questionKey = questionKey; + this.title = title; + this.rationale = rationale; + this.url = url; + this.sourceType = sourceType; + this.sortOrder = sortOrder == null ? 0 : sortOrder; + } + + public void update(String title, String rationale, String url, String sourceType, Integer sortOrder) { + this.title = title; + this.rationale = rationale; + this.url = url; + this.sourceType = sourceType; + this.sortOrder = sortOrder == null ? 0 : sortOrder; + } + + public Long getId() { return id; } + public String getQuestionKey() { return questionKey; } + public String getTitle() { return title; } + public String getRationale() { return rationale; } + public String getUrl() { return url; } + public String getSourceType() { return sourceType; } + public Integer getSortOrder() { return sortOrder; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionEvidenceJpaRepository.java b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionEvidenceJpaRepository.java new file mode 100644 index 0000000..e6b3ab4 --- /dev/null +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionEvidenceJpaRepository.java @@ -0,0 +1,14 @@ +package com.seoulection.admin.survey.infrastructure.repository; + +import com.seoulection.admin.survey.infrastructure.entity.SurveyQuestionEvidenceJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SurveyQuestionEvidenceJpaRepository extends JpaRepository { + + List findByQuestionKeyOrderBySortOrderAscIdAsc(String questionKey); + + /** 문항별로 나눠 담기 위해 한 번에 읽는다 — 문항마다 쿼리를 쏘면 문항 수만큼 늘어난다. */ + List findAllByOrderByQuestionKeyAscSortOrderAscIdAsc(); +} diff --git a/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java b/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java index 4d9c1be..43aaad9 100644 --- a/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java +++ b/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java @@ -23,9 +23,12 @@ @Controller public class SurveyAdminController { + private final com.seoulection.admin.survey.application.service.SurveyEvidenceService evidenceService; + private final SurveyAdminService service; - public SurveyAdminController(SurveyAdminService service) { + public SurveyAdminController(SurveyAdminService service, com.seoulection.admin.survey.application.service.SurveyEvidenceService evidenceService) { + this.evidenceService = evidenceService; this.service = service; } @@ -37,7 +40,7 @@ public String page(Model model) { if (!model.containsAttribute("questionRequest")) { model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); } - model.addAttribute("questions", service.getQuestions()); + populateSurveyModel(model); return "survey"; } @@ -52,7 +55,7 @@ public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQue RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { model.addAttribute("request", new SurveyOptionCreateRequest()); - model.addAttribute("questions", service.getQuestions()); + populateSurveyModel(model); model.addAttribute("questionFormOpen", true); return "survey"; } @@ -62,7 +65,7 @@ public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQue } catch (IllegalArgumentException e) { bindingResult.rejectValue("questionKey", "invalid", e.getMessage()); model.addAttribute("request", new SurveyOptionCreateRequest()); - model.addAttribute("questions", service.getQuestions()); + populateSurveyModel(model); model.addAttribute("questionFormOpen", true); return "survey"; } @@ -77,7 +80,7 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); - model.addAttribute("questions", service.getQuestions()); + populateSurveyModel(model); model.addAttribute("optionFormOpen", true); return "survey"; } @@ -89,7 +92,7 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR // 코드 중복·형식 위반은 사용자가 고칠 수 있는 입력 오류다 → 폼으로 되돌려 사유를 보여준다. bindingResult.rejectValue("code", "invalid", e.getMessage()); model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); - model.addAttribute("questions", service.getQuestions()); + populateSurveyModel(model); model.addAttribute("optionFormOpen", true); return "survey"; } @@ -139,4 +142,71 @@ public String changeQuestionActive(@PathVariable String questionKey, active ? "문항을 다시 노출합니다." : "문항을 숨겼습니다. 기존 응답은 그대로 남습니다."); return "redirect:/admin/survey"; } + + // ── 문항 근거 ───────────────────────────────────────────────────────── + // 설문이 "근거 기반"이어야 한다는 요구. 문항 하나에 근거 여러 개가 붙는다(1:N) — + // 논문 하나로 시작해도 나중에 가이드라인·임상 자료가 붙는다. + + /** 드로어에서 문항을 골라 추가한다 — 문항 카드마다 폼을 두면 같은 폼이 문항 수만큼 반복된다. */ + @PostMapping("/admin/survey/evidence") + public String addEvidence(@RequestParam String questionKey, + @RequestParam String title, + @RequestParam String rationale, + @RequestParam(required = false) String url, + @RequestParam(required = false) String sourceType, + @RequestParam(required = false) Integer sortOrder, + RedirectAttributes redirectAttributes) { + try { + evidenceService.add(questionKey, title, rationale, url, sourceType, sortOrder); + redirectAttributes.addFlashAttribute("successMessage", "근거를 추가했습니다."); + } catch (IllegalArgumentException e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + // 실패하면 드로어를 다시 열어 준다 — 닫히면 사용자가 입력한 것이 어디로 갔는지 알 수 없다. + redirectAttributes.addFlashAttribute("evidenceFormOpen", true); + } + return "redirect:/admin/survey"; + } + + @PostMapping("/admin/survey/evidence/{id}") + public String updateEvidence(@PathVariable Long id, + @RequestParam String title, + @RequestParam String rationale, + @RequestParam(required = false) String url, + @RequestParam(required = false) String sourceType, + @RequestParam(required = false) Integer sortOrder, + RedirectAttributes redirectAttributes) { + try { + evidenceService.update(id, title, rationale, url, sourceType, sortOrder); + redirectAttributes.addFlashAttribute("successMessage", "근거를 수정했습니다."); + } catch (IllegalArgumentException e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + } + return "redirect:/admin/survey"; + } + + @PostMapping("/admin/survey/evidence/{id}/delete") + public String deleteEvidence(@PathVariable Long id, RedirectAttributes redirectAttributes) { + evidenceService.delete(id); + redirectAttributes.addFlashAttribute("successMessage", "근거를 삭제했습니다."); + return "redirect:/admin/survey"; + } + + /** + * survey 템플릿이 항상 필요로 하는 것. + * + *

🔴 {@code return "survey"} 가 다섯 군데 있다(정상 렌더 하나 + 검증 실패 네 군데). + * 한 곳만 빠뜨리면 그 경로에서만 템플릿이 터지고, 화면에는 500 만 보인다 — + * 정상 흐름은 멀쩡해서 테스트로도, 눈으로도 잘 안 걸린다. + * 2026-09-08 근거 목록을 GET 에만 넣어 "중복 문항 키" 경로가 500 이 됐다. + * 모델을 손으로 채우지 말고 이 메서드를 부를 것. + */ + private void populateSurveyModel(Model model) { + model.addAttribute("questions", service.getQuestions()); + model.addAttribute("evidenceByQuestion", evidenceService.byQuestion()); + model.addAttribute("sourceTypes", SOURCE_TYPES); + } + + /** 마이그레이션의 CHECK 제약과 같은 집합. 화면의 select 가 이걸로 그려진다. */ + private static final java.util.List SOURCE_TYPES = + java.util.List.of("PAPER", "GUIDELINE", "CLINICAL", "ARTICLE", "INTERNAL"); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 418a98b..9503de9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,5 +17,39 @@ spring: thymeleaf: cache: false +# 기능성 자동 판정. 한글 이름을 저장하면 의약품안전나라(심사·보고)를 조회해 기능성까지 기록하고, +# 확정하지 못한 건만 어드민 큐에 남는다. 자세한 설계는 docs/functional-screening.md 참조. +admin: + functional-screening: + enabled: ${FUNCTIONAL_SCREENING_ENABLED:true} + # 자동 판정이 상태까지 바로 옮길지. 기본은 꺼짐 — 조회는 근거를 모아 폼을 채워 두고, + # 확정은 어드민이 저장을 눌러 한다. 일치율을 충분히 본 뒤에 켤 스위치다. + apply-decisions: ${FUNCTIONAL_SCREENING_APPLY:false} + auto-threshold: 0.95 # 이 점수 이상이면 판정 없이도 확정 후보(숫자·업체·유형 규칙은 별도) + candidate-threshold: 0.60 # 이 미만은 후보로도 남기지 않는다 + max-candidates: 8 # 이름을 일부만 적으면 같은 계열이 여럿 걸린다 + coverage-threshold: 0.95 # 이름을 일부만 적은 경우를 건지는 값(자동 확정에는 쓰지 않음) + judge-pool-size: 20 # 판정에 넘길 등록 건 수 — 유사도로 거르지 않고 통째로 보여 준다 + max-brand-aliases: 4 # 브랜드 한글 표기 후보 수. 전부 조회해 합친다 + max-search-terms: 8 # 등록명 검색어 상한(규칙 2 + 모델이 만든 변형) + # "기능성 아님"까지 자동 확정할지. 검색 실패와 구분이 어려워 기본은 꺼 둔다 — + # 브랜드 등록이 0건일 때만 켤 만하고, 선크림처럼 법적으로 기능성이어야 하는 건 제외된다. + auto-conclude-none: false + mfds: + # ⚠️ data.go.kr 발급 키를 URL 인코딩된 형태 그대로 넣는다. 비어 있으면 조회가 전부 + # FAILED로 남는다(조용히 "기능성 아님"이 되지 않게 일부러 예외를 던진다). + service-key: ${MFDS_SERVICE_KEY:} + report-url: https://apis.data.go.kr/1471000/FtnltCosmRptPrdlstInfoService/getRptPrdlstInq + exam-url: https://apis.data.go.kr/1471057/FtnltCosmSrngPrdlstInfoService04/getSrngPrdlstInq + page-size: 500 + max-brand-pages: 4 + llm: + # 켜면 브랜드 한글 표기·등록명 후보·후보 판정을 Gemini가 맡는다. + # 꺼져 있으면 규칙 기반 대체 구현이 돌고, 애매한 건 전부 사람 큐로 간다. + # ⚠️ 켜도 api-key가 비면 호출을 건너뛰고 규칙만으로 판정한다(조용히 죽지 않게 로그를 남긴다). + enabled: ${FUNCTIONAL_SCREENING_LLM:false} + model: ${GEMINI_MODEL:gemini-3.8-flash} + api-key: ${GEMINI_API_KEY:} + server: port: ${SERVER_PORT:8081} diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index ac51a2b..1969c78 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -278,6 +278,17 @@ textarea { } textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft-strong); } +select { + width: 100%; + height: 42px; + padding: 0 13px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-primary); + font: inherit; +} +select:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft-strong); } .form-help { margin-top: 6px; font-size: 12px; color: var(--text-secondary); } .section-heading-row { @@ -323,6 +334,37 @@ textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: .button-secondary:hover { background: var(--surface-sunken); color: var(--text-primary); } .form-actions button, .form-actions .button-secondary { margin-top: 0; } .form-actions .button-secondary { margin-right: 4px; } +.button-primary { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + margin: 0; + padding: 0 16px; + border: 0; + border-radius: var(--radius-sm); + background: var(--accent); + color: white; + font: inherit; + font-size: 13.5px; + font-weight: 700; +} +.button-primary:disabled { background: #b9bddf; cursor: not-allowed; } +.notification-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 20px; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-sunken); +} +.notification-preview .field-label { margin-bottom: 4px; color: var(--text-secondary); } +.notification-preview strong { display: block; font-size: 15px; } +.notification-preview p { margin-top: 5px; color: var(--text-secondary); font-size: 12.5px; } +.notification-empty-state { padding: 30px 24px; } .data-display { display: flex; flex-direction: column; gap: 5px; } .data-display strong { font-size: 14px; } .data-display small { color: var(--text-tertiary); font-size: 11.5px; } @@ -1040,3 +1082,101 @@ td a:hover { text-decoration: underline; } .topbar { padding: 0 20px; } .form-grid, .form-grid.cols-4 { grid-template-columns: 1fr; } } + + + + + + +/* ───────────────────────────────────────────────────────────────────────────── + 외부 호출 대기 표시. + + 의약품안전나라 조회는 브랜드 전수(최대 4페이지 × 500건)에 심사·보고 두 곳을 부르고, + LLM 을 켜면 이름 해석까지 붙어 몇 초가 걸린다. 그동안 화면이 그대로면 어드민이 + 버튼을 다시 누르고, 그 사이 같은 조회가 두 번 돈다. + ───────────────────────────────────────────────────────────────────────────── */ +.is-busy { + position: relative; + pointer-events: none; + opacity: 0.65; +} + +.spinner { + display: inline-block; + width: 0.85em; + height: 0.85em; + margin-right: 0.45em; + vertical-align: -0.1em; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* 화면 전체가 대기 중임을 알리는 얇은 띠. 버튼만 회색이 되면 "눌린 건가?"가 된다. */ +.busy-bar { + position: fixed; + inset: 0 0 auto 0; + height: 3px; + background: linear-gradient(90deg, transparent, var(--accent, #4c6ef5), transparent); + background-size: 40% 100%; + background-repeat: no-repeat; + animation: busy-slide 1.1s ease-in-out infinite; + z-index: 9999; +} + +@keyframes busy-slide { + 0% { background-position: -40% 0; } + 100% { background-position: 140% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .spinner, .busy-bar { animation: none; } +} + + +/* 설문 문항 근거 — 접힌 상태는 한 줄이다. 표에 textarea 를 두니 근거 하나가 + 서너 줄을 먹어 선택지 편집이 스크롤 아래로 밀렸다. */ +.evidence-list { margin-top: 10px; border-top: 1px solid var(--border); } +.evidence { border-bottom: 1px solid var(--border); } +.evidence > summary { + display: flex; align-items: center; gap: 8px; + padding: 8px 2px; cursor: pointer; list-style: none; +} +.evidence > summary::-webkit-details-marker { display: none; } +.evidence > summary::before { + content: "▸"; color: var(--text-secondary); font-size: 11px; transition: transform .12s; +} +.evidence[open] > summary::before { content: "▾"; } +.evidence > summary strong { font-size: 13px; white-space: nowrap; } +/* 이유는 접힌 줄에서 미리 보인다 — 펼치지 않고도 무엇에 대한 근거인지 알 수 있게. */ +.evidence-preview { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text-secondary); font-size: 12px; +} +.evidence-body { padding: 4px 2px 12px 18px; display: grid; gap: 8px; } +.evidence-rationale { margin: 0; color: var(--text-secondary); font-size: 12.5px; line-height: 1.6; } +.evidence-form textarea { width: 100%; min-width: 240px; resize: vertical; } +.empty-inline { margin: 10px 0 0; color: var(--text-secondary); font-size: 12.5px; } + + +/* ───────────────────────────────────────────────────────────────────────────── + 자동 조회 후보 표에서 "판정이 고른 행". + + 전에는 .is-selected 클래스만 붙이고 스타일을 안 만들어서, 모델이 고른 것이 화면에 + 전혀 드러나지 않았다. 후보가 여덟 줄 깔린 표에서 어느 것을 보라는 건지 알 수 없으면 + 자동 조회를 돌린 의미가 없다. + ───────────────────────────────────────────────────────────────────────────── */ +.data-table tr.is-selected > td { + background: color-mix(in srgb, var(--accent, #4c6ef5) 8%, transparent); + box-shadow: inset 2px 0 0 var(--accent, #4c6ef5); +} + +.data-chip.is-picked { + background: var(--accent, #4c6ef5); + color: #fff; + font-weight: 600; + margin-right: 0.35em; +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 434c585..29b5169 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -78,3 +78,35 @@ const serverOpened = document.querySelector('.drawer.is-open'); if (serverOpened) open(serverOpened, null); })(); + + +/* ───────────────────────────────────────────────────────────────────────────── + 외부 호출(안전나라 조회·LLM 이름 해석) 대기 표시. + + data-busy="문구" 가 붙은 폼이 제출되면 버튼을 잠그고 문구를 바꾼다. 잠그는 게 핵심이다 — + 응답이 늦으면 어드민이 한 번 더 누르고, 서버는 같은 외부 API 를 두 번 부른다. + + ⚠️ 버튼을 disabled 로 만들면 그 버튼의 name/value 가 폼 데이터에서 빠진다. 여기서는 + 제출값으로 쓰는 버튼이 없어 문제가 없지만, 값을 실어 보내는 버튼에 붙일 때는 + hidden 으로 옮기고 잠글 것. + ───────────────────────────────────────────────────────────────────────────── */ +(() => { + const showBar = () => { + if (document.querySelector('.busy-bar')) return; + const bar = document.createElement('div'); + bar.className = 'busy-bar'; + document.body.appendChild(bar); + }; + + document.querySelectorAll('form[data-busy]').forEach((form) => { + form.addEventListener('submit', () => { + const label = form.dataset.busy || '처리 중…'; + form.querySelectorAll('button[type="submit"], input[type="submit"]').forEach((button) => { + button.classList.add('is-busy'); + button.innerHTML = '' + label; + }); + form.classList.add('is-busy'); + showBar(); + }); + }); +})(); diff --git a/src/main/resources/templates/fragments/ingredient-rows.html b/src/main/resources/templates/fragments/ingredient-rows.html new file mode 100644 index 0000000..8143a91 --- /dev/null +++ b/src/main/resources/templates/fragments/ingredient-rows.html @@ -0,0 +1,207 @@ + + + +

+
+
+

성분별 보완

+

+ 보완 대상 + 0건 / + 사전 미연결 0건 — + 성분 사전에 연결된 성분만 함량·특성을 입력합니다. 미연결 성분은 추천 계산에 + 쓰이지 않아 지금 채워 둘 값이 없습니다. +

+
+ 성분 사전 +
+ +
+

+ 성분 사전에 연결된 성분이 없어 보완할 항목이 없습니다. + 아래 목록의 성분을 성분 사전에 + 먼저 추가하면 여기에 입력 칸이 생깁니다. +

+
+ +
+
+
+ 1 + 성분명 + 사전 연결 + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ 이 제품에서의 특성 + + + + + + + +
+
+
+ 특성 + + +
+ +
+ + + +
+
+ + + +
+
+
+ +
+ + +
+

빈 칸은 저장하지 않습니다 — 값을 지우거나 삭제하면 그 특성이 사라집니다.

+
+
+ 정의된 특성이 없습니다. 특성 정의에서 먼저 추가하세요. +
+ +
+ + +
+ + +
+
+ +
+
+ 사전에 없어 보완하지 않는 성분 + +
+
+ +
+

+ 사전에 추가하면 다음 저장부터 자동으로 연결돼 이 화면에 입력 칸이 생깁니다. + 성분 사전에서 추가 +

+
+
+ + + diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/fragments/sidebar.html index d379d0e..8538f91 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/fragments/sidebar.html @@ -63,6 +63,18 @@ 피부 점수 정책 + + + + + 푸시 알림 + + + + + + 기기 토큰 + diff --git a/src/main/resources/templates/notification-devices.html b/src/main/resources/templates/notification-devices.html new file mode 100644 index 0000000..80779eb --- /dev/null +++ b/src/main/resources/templates/notification-devices.html @@ -0,0 +1,80 @@ + + + + + + 기기 토큰 관리 | MySeoulection Admin + + + +
+ +
+
+
+

기기 토큰 관리

+

+ 푸시가 안 오거나 지운 기기로 계속 오는 문의를 다룹니다. 해제하면 그 기기로는 더 이상 발송되지 않습니다. +

+
+ 푸시 발송 +
+ +
+
+ +
+
+
+

사용자 기기 조회

+

사용자 ID 로 살아 있는 등록만 보여 줍니다.

+
+
+
+
+
+ + +

토큰으로는 찾지 않습니다 — 입력하면 그 값이 로그와 브라우저 기록에 남습니다.

+
+
+ +
+
+ +
+
+
+

등록된 기기0

+

토큰은 앞뒤 일부만 보여 줍니다. 전문이 있으면 그것만으로 발송할 수 있습니다.

+
+
+ + + + + + + + + + + + +
플랫폼토큰해제
IOSabc123…7f9d +
+ + +
+
+ +
+ 살아 있는 기기 등록이 없습니다. 앱에서 알림을 한 번 켜야 등록됩니다. +
+
+
+
+
+ + diff --git a/src/main/resources/templates/notifications.html b/src/main/resources/templates/notifications.html new file mode 100644 index 0000000..ea44f74 --- /dev/null +++ b/src/main/resources/templates/notifications.html @@ -0,0 +1,91 @@ + + + + + + 푸시 알림 | MySeoulection Admin + + + +
+ +
+
+
+

푸시 알림

+

대상과 문구를 검토한 뒤 수동 알림을 발송합니다.

+
+
+ +
+

+ 현재는 화면만 준비된 상태입니다. 대상 수 확인, 실제 발송, 발송 결과는 notification-service를 연결한 뒤 활성화됩니다. +

+ +
+
+
+

새 수동 발송

+

발송할 대상과 문구를 입력하고, 대상 수를 확인한 뒤 발송합니다.

+
+ 초안 +
+ +
+
+
+ + +

처음에는 활성 기기 기준 전체 발송만 지원합니다.

+
+
+ + +

예약 발송은 이후 별도 기능으로 추가합니다.

+
+
+ + +

최대 50자

+
+
+ + +

최대 180자

+
+
+ +
+
+ 발송 전 대상 수 + notification-service 연결 후 계산 +

중복 기기를 제외한 사용자 수와 실제 발송 기기 수를 함께 보여 줍니다.

+
+ +
+
+ +
+
+
+ +
+
+
+

발송 결과

+

최근 수동 발송의 대상 수와 전달 결과를 확인합니다.

+
+
+
+ 아직 표시할 발송 이력이 없습니다. notification-service 연동 후 발송 결과가 이곳에 쌓입니다. +
+
+
+
+
+ + diff --git a/src/main/resources/templates/product-detail.html b/src/main/resources/templates/product-detail.html index 4ca919e..702a13b 100644 --- a/src/main/resources/templates/product-detail.html +++ b/src/main/resources/templates/product-detail.html @@ -86,6 +86,9 @@

전성분< + +
+
diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index 06310ed..e0e5294 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -66,6 +66,24 @@

+ +
+ + + +
@@ -78,9 +96,98 @@

등록된 성분이 없습니다.

+ +
+
한글 이름으로 자동 조회
+
+
+ + +

+ 저장과 동시에 의약품안전나라(기능성화장품 심사·보고)를 조회해 기능성 여부를 기록합니다. + 확정되면 바로 다음 단계로 넘어가고, 결과를 찾지 못했을 때만 아래에서 직접 입력합니다. +

+
+
+ +
+ + +
+
+ 자동 조회 결과 + +
+

+

+ +

+ 정확히 맞는 등록 건은 없습니다. 아래는 같은 브랜드의 등록 건이라 + 참고로 보여 드립니다 — 유사도가 낮으니 이름을 직접 견줘 보세요. +

+ + + + + + + + + + + + + + + + + +
등록명업체등록일효능효과도출 유형점수
+ + + AI 지목 + + + + + + + + + + + +
+

+
+
+
직접 입력자동 조회가 확정하지 못했을 때
+
+ 제품 한글 이름 + +

사용자 화면에서 영문명 대신 보여 줍니다. 비워 두면 기존 값을 유지합니다.

+
의약품안전나라 조회 결과
@@ -141,8 +248,22 @@

}; statusInputs.forEach(input => input.addEventListener('change', syncFunctional)); syncFunctional(); + + // 자동 조회 후보를 아래 폼에 채운다. 라디오까지 대신 눌러 주는 이유는, 후보를 + // 고른 행위 자체가 "기능성 확인"이라는 판단이기 때문이다. 반대로 후보가 없을 때 + // 'NONE'을 미리 찍어 두지는 않는다 — 확인 없이 저장만 눌러도 '기능성 아님'이 + // 사실로 기록되면 안 된다. + document.querySelectorAll('.js-apply-candidate').forEach(button => { + button.addEventListener('click', () => { + const claims = (button.dataset.claims || '').split(',').filter(Boolean); + document.querySelector('input[name="functionResult"][value="CONFIRMED"]').checked = true; + syncFunctional(); + categoryInputs.forEach(input => { input.checked = claims.includes(input.value); }); + }); + }); } })(); + diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html index ce063a0..8cc4bcd 100644 --- a/src/main/resources/templates/products.html +++ b/src/main/resources/templates/products.html @@ -179,6 +179,11 @@

제품 등록

+
+ + +

사용자 화면에서 영문명 대신 보여 줍니다. 지금 비워 두면 기능성 확인 탭에서 넣을 수 있습니다.

+
카테고리
@@ -200,7 +205,8 @@

제품 등록

-

파이프라인이 성분을 가져오지 못한 제품에만 사용합니다. 비워두면 파이프라인이 먼저 시도합니다.

+

파이프라인이 성분을 가져오지 못한 제품에만 사용합니다. 비워두면 파이프라인이 먼저 시도합니다. +
여기에 성분을 넣으면 등록 직후 함량 입력 화면으로 이어집니다.

diff --git a/src/main/resources/templates/survey.html b/src/main/resources/templates/survey.html index 93a2f19..172a043 100644 --- a/src/main/resources/templates/survey.html +++ b/src/main/resources/templates/survey.html @@ -17,6 +17,7 @@

설문 관리

+ @@ -53,6 +54,48 @@

설문 관리

+ +
+
+ + 논문 + 제목 + 묻는 이유 + +
+

묻는 이유

+ 원문 열기 + + + + + + + + + + +
+ +
+
+
+
+

+ 근거가 없습니다. 이 문항을 왜 묻는지 남겨 두면 나중에 고치거나 뺄 때 판단할 수 있습니다. +

+
@@ -201,6 +244,71 @@

선택지 추가

+ + + diff --git a/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java b/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java index 6941071..1cb170f 100644 --- a/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java +++ b/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java @@ -25,13 +25,45 @@ private Product crawledButTooFew() { } @Test - @DisplayName("성분을 채워 저장하면 기능성 확인 단계로 넘어간다") - void fillingIngredientsMovesToFunctionalQueue() { + @DisplayName("전성분만 저장해서는 다음 단계로 넘어가지 않는다 — 성분별 보완이 남아 있다") + void savingIngredientsAloneDoesNotAdvance() { Product reviewed = crawledButTooFew() .reviewIngredients(List.of("Water", "Glycerin", "Niacinamide", "Panthenol", "Ceramide NP"), false); - assertThat(reviewed.status()).isEqualTo(ProductStatus.INGREDIENTS_ADDED); - assertThat(reviewed.ingredientSource()).isEqualTo(Product.ADMIN_SOURCE); + // 상태는 저장 전 그대로다 — 어드민 저장이 파이프라인 판정을 지우지도, 다음 단계로 + // 밀지도 않는다. 앞으로 미는 건 '성분 보완 완료' 뿐이다. + assertThat(reviewed.status()).isEqualTo(ProductStatus.INSUFFICIENT_INGREDIENTS); + assertThat(reviewed.ingredients()).hasSize(5); + } + + @Test + @DisplayName("성분별 보완을 마쳐야 성분 입력 완료가 된다") + void completingReviewAdvances() { + Product reviewed = crawledButTooFew() + .reviewIngredients(List.of("Water", "Glycerin", "Niacinamide"), false); + + assertThat(reviewed.completeIngredientReview().status()).isEqualTo(ProductStatus.INGREDIENTS_ADDED); + } + + @Test + @DisplayName("함량을 하나도 안 채웠어도 완료할 수 있다 — 채울 값이 없는 제품이 있다") + void completingWithoutConcentrationsIsAllowed() { + Product reviewed = crawledButTooFew().reviewIngredients(List.of("Water"), false); + + assertThat(reviewed.completeIngredientReview().status()).isEqualTo(ProductStatus.INGREDIENTS_ADDED); + } + + @Test + @DisplayName("성분이 없으면 완료할 수 없다 — 아직 1단계도 끝나지 않았다") + void cannotCompleteWithoutIngredients() { + Product noIngredients = Product.builder() + .name("시카 세럼").brand("서울렉션").category("treatments") + .status(ProductStatus.NEED_MANUAL_REVIEW) + .build(); + + org.assertj.core.api.Assertions.assertThatThrownBy(noIngredients::completeIngredientReview) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("전성분"); } @Test diff --git a/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java b/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java new file mode 100644 index 0000000..c8c3389 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java @@ -0,0 +1,191 @@ +package com.seoulection.admin.product.functional.application; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.port.CandidateVerdict; +import com.seoulection.admin.product.functional.application.port.FunctionalScreeningRepository; +import com.seoulection.admin.product.functional.application.port.MfdsCatalogPort; +import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; +import com.seoulection.admin.product.functional.application.port.ScreeningTarget; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.MfdsSource; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 자동 판정의 경계를 고정한다. 여기 있는 케이스는 전부 실제 안전나라 응답에서 관찰된 모양이다. + */ +class FunctionalScreeningServiceTest { + + private ProductService productService; + private FakeCatalog catalog; + private FunctionalScreeningRepository repository; + private FunctionalScreeningProperties properties; + private ProductNameResolverPort resolver; + + @BeforeEach + void setUp() { + productService = mock(ProductService.class); + catalog = new FakeCatalog(); + repository = mock(FunctionalScreeningRepository.class); + properties = new FunctionalScreeningProperties(); + resolver = mock(ProductNameResolverPort.class); + // (String) 캐스팅을 빼면 List.of(E...) 오버로드가 잡혀 String이 Object[]로 캐스팅된다. + when(resolver.koreanBrandAliases(anyString())).thenAnswer(call -> List.of((String) call.getArgument(0))); + when(resolver.registrationNameCandidates(any(), anyString())).thenReturn(List.of()); + when(resolver.judge(any(), anyList())).thenReturn(CandidateVerdict.none("판정 없음")); + } + + private FunctionalScreeningService service() { + return new FunctionalScreeningService(productService, catalog, resolver, repository, properties); + } + + private ProductResult product(String nameKo, String brand, String category) { + return new ProductResult("p1", null, "Goodal Serum", nameKo, brand, category, null, + BigDecimal.ZERO, null, null, 0, BigDecimal.ZERO, null, "ADMIN", + List.of("정제수"), Map.of(), null, List.of(), ProductStatus.INGREDIENTS_ADDED); + } + + private MfdsItem item(String itemName, String entpName, String eeName, String spf, String pa) { + return new MfdsItem(MfdsSource.REPORT, itemName, entpName, null, eeName, spf, pa, + "제10조 제1항 제1호", "20240101", false); + } + + @Test + @DisplayName("적용 모드를 켜면 자동 확정이 상태까지 옮긴다") + void confirmsAndAdvancesStatus() { + properties.setApplyDecisions(true); + catalog.brand("구달", List.of( + item("구달청귤비타씨잡티세럼", "(주)클리오", + "피부의 미백에 도움을 준다. 피부의 주름개선에 도움을 준다.", null, null))); + + FunctionalScreening screening = service().screen(product("청귤 비타씨 잡티 세럼", "구달", "treatments")); + + assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.AUTO_CONFIRMED); + assertThat(screening.claims()).containsExactlyInAnyOrder( + ProductFunctionalCategory.WHITENING, ProductFunctionalCategory.WRINKLE_IMPROVEMENT); + verify(productService).reviewFunction("p1", List.of("WHITENING", "WRINKLE_IMPROVEMENT")); + } + + @Test + @DisplayName("숫자가 다른 후보는 점수가 높아도 자동 확정하지 않는다") + void doesNotConfirmWhenNumbersDiffer() { + catalog.brand("닥터디퍼런트", List.of( + item("닥터디퍼런트131모이스처라이저", "주식회사다른코스메틱스", + "피부의 주름개선에 도움을 준다.", null, null))); + + FunctionalScreening screening = service().screen(product("311 모이스처라이저", "닥터디퍼런트", "moisturizers")); + + assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.NEEDS_REVIEW); + assertThat(screening.hasCandidates()).isTrue(); // 후보는 보여 주되 확정하지 않는다 + verify(productService, never()).reviewFunction(anyString(), anyList()); + } + + @Test + @DisplayName("선크림은 등록 건을 못 찾아도 '기능성 아님'으로 접지 않는다") + void neverAutoConcludesNoneForSunscreen() { + properties.setAutoConcludeNone(true); + catalog.brand("디오디너리", List.of()); // 브랜드 등록 0건 + + FunctionalScreening screening = service().screen(product("수분 선크림", "디오디너리", "sunscreens")); + + assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.NOT_MATCHED); + verify(productService, never()).reviewFunction(anyString(), anyList()); + } + + @Test + @DisplayName("브랜드 등록이 0건이면 옵션을 켰을 때만 '기능성 아님'을 자동 확정한다") + void autoConcludesNoneOnlyWhenEnabled() { + catalog.brand("꼬달리", List.of()); + ProductResult target = product("뷰티 엘릭시르", "꼬달리", "toners"); + + assertThat(service().screen(target).outcome()).isEqualTo(ScreeningOutcome.NOT_MATCHED); + + properties.setAutoConcludeNone(true); + properties.setApplyDecisions(true); + FunctionalScreening screening = service().screen(target); + assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.AUTO_NONE); + assertThat(screening.claims()).isEmpty(); + verify(productService).reviewFunction("p1", List.of()); + } + + @Test + @DisplayName("조회가 터지면 FAILED로 남기고 상태를 옮기지 않는다") + void keepsFailuresOutOfTheDecision() { + catalog.failWith(new IllegalStateException("안전나라 응답 오류 30: SERVICE KEY IS NOT REGISTERED")); + + FunctionalScreening screening = service().screen(product("청귤 세럼", "구달", "treatments")); + + assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.FAILED); + assertThat(screening.reason()).contains("SERVICE KEY"); + verify(productService, never()).reviewFunction(anyString(), anyList()); + } + + @Test + @DisplayName("기본값에서는 판정만 기록하고 상태는 어드민이 확정한다") + void doesNotAdvanceByDefault() { + catalog.brand("구달", List.of( + item("구달청귤비타씨잡티세럼", "(주)클리오", "피부의 미백에 도움을 준다.", null, null))); + + FunctionalScreening screening = service().screen(product("청귤 비타씨 잡티 세럼", "구달", "treatments")); + + assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.AUTO_CONFIRMED); + verify(repository).save(any()); + verify(productService, never()).reviewFunction(anyString(), anyList()); + } + + /** 안전나라 대역. item_name 부분 일치만 흉내 낸다 — 실제 API가 그것만 지원하기 때문이다. */ + private static class FakeCatalog implements MfdsCatalogPort { + private final Map> byBrand = new HashMap<>(); + private RuntimeException failure; + + void brand(String brandKo, List items) { + byBrand.put(brandKo, items); + } + + void failWith(RuntimeException e) { + this.failure = e; + } + + @Override + public List searchByItemName(String term) { + if (failure != null) { + throw failure; + } + List matched = new ArrayList<>(); + byBrand.values().forEach(items -> items.stream() + .filter(item -> item.itemName().contains(term) || term.contains(item.itemName())) + .forEach(matched::add)); + return matched; + } + + @Override + public List searchBrand(String brandKo) { + if (failure != null) { + throw failure; + } + return byBrand.getOrDefault(brandKo, List.of()); + } + } +} diff --git a/src/test/java/com/seoulection/admin/product/functional/domain/FunctionalClaimsTest.java b/src/test/java/com/seoulection/admin/product/functional/domain/FunctionalClaimsTest.java new file mode 100644 index 0000000..41428c6 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/functional/domain/FunctionalClaimsTest.java @@ -0,0 +1,56 @@ +package com.seoulection.admin.product.functional.domain; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 실제 안전나라 응답에서 관찰된 행 모양을 그대로 고정한다. */ +class FunctionalClaimsTest { + + private static MfdsItem item(String eeName, String spf, String pa) { + return new MfdsItem(MfdsSource.REPORT, "테스트제품", "(주)테스트", null, eeName, spf, pa, + "제10조 제1항 제1호", "20240101", false); + } + + @Test + @DisplayName("효능효과 문구 하나에 두 유형이 들어 있으면 둘 다 읽는다") + void readsMultipleClaimsFromOneSentence() { + ClaimReading reading = FunctionalClaims.read( + item("피부의 미백에 도움을 준다. 피부의 주름개선에 도움을 준다.", null, null)); + + assertThat(reading.categories()).containsExactlyInAnyOrder( + ProductFunctionalCategory.WHITENING, ProductFunctionalCategory.WRINKLE_IMPROVEMENT); + assertThat(reading.autoConfirmable()).isTrue(); + } + + @Test + @DisplayName("효능효과가 비어 있어도 SPF/PA가 있으면 자외선 차단이다") + void inferesUvProtectionFromSpf() { + // 선크림 보고 건의 전형: EE_NAME이 null이고 SPF·PA만 채워져 온다. + ClaimReading reading = FunctionalClaims.read(item(null, "50+", "4")); + + assertThat(reading.categories()).containsExactly(ProductFunctionalCategory.UV_PROTECTION); + assertThat(reading.derivable()).isTrue(); + } + + @Test + @DisplayName("염모는 기능성이지만 우리 분류 밖이라 자동 확정하지 않는다") + void hairDyeIsOutOfScope() { + ClaimReading reading = FunctionalClaims.read(item("모발의 염모", null, null)); + + assertThat(reading.categories()).isEmpty(); + assertThat(reading.outOfScope()).isTrue(); + assertThat(reading.autoConfirmable()).isFalse(); + } + + @Test + @DisplayName("효능효과도 SPF도 없으면 도출 불가 — 사람이 봐야 한다") + void undecidableWithoutAnyEvidence() { + ClaimReading reading = FunctionalClaims.read(item(null, null, null)); + + assertThat(reading.derivable()).isFalse(); + assertThat(reading.autoConfirmable()).isFalse(); + } +} diff --git a/src/test/java/com/seoulection/admin/product/functional/domain/ItemNameTest.java b/src/test/java/com/seoulection/admin/product/functional/domain/ItemNameTest.java new file mode 100644 index 0000000..2ff16c7 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/functional/domain/ItemNameTest.java @@ -0,0 +1,41 @@ +package com.seoulection.admin.product.functional.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ItemNameTest { + + @Test + @DisplayName("공백·괄호를 지우면 등록명과 같은 모양이 된다") + void normalizesToRegistrationShape() { + assertThat(ItemName.normalize("달바 워터풀 톤업 선크림 [SPF50+/PA++++]")) + .isEqualTo("달바워터풀톤업선크림"); + } + + @Test + @DisplayName("중간에 단어가 끼어들어도 유사도가 살아 있다") + void toleratesInsertedWords() { + // 실제 사례: 뉴트로지나딥클린포밍클렌저 ↔ 뉴트로지나딥클린아크네포밍클렌저(보) + assertThat(ItemName.similarity("뉴트로지나 딥클린 포밍 클렌저", "뉴트로지나딥클린아크네포밍클렌저(보)")) + .isGreaterThan(0.85); + } + + @Test + @DisplayName("숫자 한 자리가 다르면 유사도가 높아도 다른 제품으로 표시된다") + void numericTokensGuardAgainstLookalikes() { + // 이 두 이름의 유사도는 0.94다 — 점수만 보면 자동 확정될 뻔한 실제 사례. + String ours = "닥터디퍼런트 311 모이스처라이저"; + String theirs = "닥터디퍼런트131모이스처라이저"; + + assertThat(ItemName.similarity(ours, theirs)).isGreaterThan(0.9); + assertThat(ItemName.numericTokensMatch(ours, theirs)).isFalse(); + } + + @Test + @DisplayName("양쪽 다 숫자가 없으면 숫자 규칙은 통과다") + void numericRulePassesWhenNoDigits() { + assertThat(ItemName.numericTokensMatch("구달 청귤 세럼", "구달청귤비타씨세럼")).isTrue(); + } +} diff --git a/src/test/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClientLiveTest.java b/src/test/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClientLiveTest.java new file mode 100644 index 0000000..e4a335e --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClientLiveTest.java @@ -0,0 +1,74 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.functional.application.FunctionalScreeningProperties; +import com.seoulection.admin.product.functional.domain.FunctionalClaims; +import com.seoulection.admin.product.functional.domain.ItemName; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 실제 의약품안전나라를 부르는 확인용 테스트. {@code MFDS_SERVICE_KEY}가 있을 때만 돈다. + * + *

여기 있는 이유: 이 연동에서 깨지는 건 대개 우리 로직이 아니라 키 인코딩과 응답 스키마다. + * 단위 테스트는 대역을 쓰므로 그 둘을 못 잡는다. 키를 새로 발급했거나 조회가 이상할 때 + * {@code MFDS_SERVICE_KEY=... ./gradlew test --tests '*MfdsCatalogClientLiveTest*'} 로 확인한다. + */ +@EnabledIfEnvironmentVariable(named = "MFDS_SERVICE_KEY", matches = ".+") +class MfdsCatalogClientLiveTest { + + private MfdsCatalogClient client() { + FunctionalScreeningProperties properties = new FunctionalScreeningProperties(); + properties.getMfds().setServiceKey(System.getenv("MFDS_SERVICE_KEY")); + return new MfdsCatalogClient(properties); + } + + @Test + @DisplayName("브랜드 전수 조회로 제품을 찾고 기능성 유형까지 도출한다") + void findsProductThroughBrandScan() { + List items = client().searchBrand("구달"); + assertThat(items).isNotEmpty(); + + String query = "구달청귤비타씨잡티세럼"; + MfdsItem best = items.stream() + .max(Comparator.comparingDouble(item -> ItemName.similarity(query, item.itemName()))) + .orElseThrow(); + + System.out.println("[브랜드 전수] 구달 " + items.size() + "건, 최고 후보: " + best.itemName() + + " / " + best.entpName() + " / " + FunctionalClaims.read(best).categories()); + + assertThat(ItemName.similarity(query, best.itemName())).isGreaterThan(0.9); + assertThat(best.entpName()).contains("클리오"); + assertThat(FunctionalClaims.read(best).autoConfirmable()).isTrue(); + } + + @Test + @DisplayName("효능효과가 비어 있는 선크림도 SPF/PA로 자외선 차단이 도출된다") + void derivesUvProtectionFromSpf() { + List items = client().searchByItemName("달바워터풀톤업선크림"); + assertThat(items).isNotEmpty(); + + MfdsItem sunscreen = items.get(0); + System.out.println("[선크림] " + sunscreen.itemName() + " / EE=" + sunscreen.eeName() + + " / SPF=" + sunscreen.spf() + " PA=" + sunscreen.pa() + + " → " + FunctionalClaims.read(sunscreen).categories()); + + assertThat(FunctionalClaims.read(sunscreen).categories()).contains(ProductFunctionalCategory.UV_PROTECTION); + } + + @Test + @DisplayName("키가 비어 있으면 빈 목록이 아니라 예외다 — 조용히 '기능성 아님'이 되면 안 된다") + void missingKeyFailsLoudly() { + MfdsCatalogClient noKey = new MfdsCatalogClient(new FunctionalScreeningProperties()); + + assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> noKey.searchByItemName("구달")).getMessage()).contains("서비스 키"); + } +} diff --git a/src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java b/src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java new file mode 100644 index 0000000..3a7b12c --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java @@ -0,0 +1,97 @@ +package com.seoulection.admin.product.infrastructure; + +import com.seoulection.admin.product.application.dto.ProductIngredientResult; +import com.seoulection.admin.product.infrastructure.repository.ApiProductIngredientAdapter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * API 어댑터가 product-service 응답을 JDBC 어댑터와 같은 모양으로 옮기는지 본다. + * + *

왜 이 테스트인가: 두 어댑터를 실제로 나란히 돌리려면 Postgres 와 product-service 가 둘 다 + * 떠 있어야 한다. 그런데 실제로 깨지는 지점은 대부분 그게 아니라 필드 이름 어긋남이다 — + * V2 가 응답 필드를 바꾸면 여기서 조용히 null 이 되고, 화면에는 빈 칸으로만 보인다. + * 그 어긋남은 HTTP 를 흉내 내는 것만으로 잡을 수 있다. + */ +class ApiProductIngredientAdapterTest { + + private static final String BASE_URL = "http://product-service:8080"; + + @Test + @DisplayName("★ product-service 응답을 화면이 쓰는 결과로 옮긴다 — 필드 이름이 어긋나면 여기서 걸린다") + void mapsResponseToResult() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + var adapter = new ApiProductIngredientAdapter(builder, BASE_URL, "secret"); + + server.expect(requestTo(BASE_URL + "/internal/admin/v1/products/p1/ingredients")) + .andExpect(header("X-Service-Key", "secret")) + .andRespond(withSuccess(""" + [ + {"id": 7, "ingredientId": "ing-1", "rawName": "Sodium Hyaluronate", "order": 1, + "concentrationMin": 1.0, "concentrationMax": 2.5, "unit": "%", + "notes": "저분자", "source": "ADMIN", + "inciName": "SODIUM HYALURONATE", "displayNameKo": "소듐하이알루로네이트", + "properties": [ + {"propertyKey": "PURITY", "displayNameKo": "순도", "valueText": null, + "valueMin": 99, "valueMax": 99, "valueUnit": "%", "notes": null} + ]}, + {"id": 8, "ingredientId": null, "rawName": "Unknown Extract", "order": 2, + "concentrationMin": null, "concentrationMax": null, "unit": null, + "notes": null, "source": "PIPELINE", + "inciName": null, "displayNameKo": null, "properties": []} + ] + """, MediaType.APPLICATION_JSON)); + + List rows = adapter.findByProductId("p1"); + + assertThat(rows).hasSize(2); + + ProductIngredientResult matched = rows.get(0); + assertThat(matched.id()).isEqualTo(7); + assertThat(matched.matched()).isTrue(); + assertThat(matched.rawName()).isEqualTo("Sodium Hyaluronate"); + assertThat(matched.order()).isEqualTo(1); + assertThat(matched.concentrationMin()).isEqualByComparingTo(BigDecimal.ONE); + assertThat(matched.unit()).isEqualTo("%"); + // 사전 이름이 오면 화면은 한글명을 쓴다 — JDBC 어댑터와 같은 규칙이다. + assertThat(matched.displayName()).isEqualTo("소듐하이알루로네이트"); + assertThat(matched.properties()).singleElement() + .satisfies(property -> { + assertThat(property.propertyKey()).isEqualTo("PURITY"); + assertThat(property.display()).isEqualTo("99 %"); + }); + + // 사전에 없는 성분은 오류가 아니다. 원문을 그대로 보여 준다. + ProductIngredientResult unmatched = rows.get(1); + assertThat(unmatched.matched()).isFalse(); + assertThat(unmatched.displayName()).isEqualTo("Unknown Extract"); + assertThat(unmatched.properties()).isEmpty(); + + server.verify(); + } + + @Test + @DisplayName("특성 정의는 V2에 API가 없다 — 빈 목록 대신 명시적으로 실패한다") + void propertyDefinitionsFailsLoudly() { + RestClient.Builder builder = RestClient.builder(); + var adapter = new ApiProductIngredientAdapter(builder, BASE_URL, "secret"); + + // 빈 목록을 주면 화면에 특성 칸이 안 뜨는데 그게 "고장"이 아니라 "입력할 게 없음"으로 + // 보인다. 원인을 찾기 어려우므로 여기서 터뜨린다. + assertThatThrownBy(adapter::propertyDefinitions) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("property-definitions"); + } +} diff --git a/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java b/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java new file mode 100644 index 0000000..822d76c --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java @@ -0,0 +1,94 @@ +package com.seoulection.admin.product.presentation; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.functional.domain.ClaimReading; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.MfdsCandidate; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.MfdsSource; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import com.seoulection.admin.product.presentation.controller.ProductController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 기능성 탭이 자동 조회 결과를 실제로 그려 내는지 본다. + * + *

이 테스트가 있는 이유: Thymeleaf 표현식 오류는 컴파일에 걸리지 않고 화면을 열어야만 + * 드러난다. 특히 enum 메서드는 {@code status.tone()}처럼 괄호를 붙여야 하고, 레코드 컴포넌트는 + * 안 붙여야 한다 — 한 글자 차이로 500이 난다. + */ +@WebMvcTest(ProductController.class) +class FunctionalWorkflowViewTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + ProductService service; + + @MockitoBean + FunctionalScreeningService screeningService; + + @Test + @DisplayName("확정하지 못한 판정은 후보 표와 보류 사유까지 그린다") + void rendersCandidatesAndBlockReason() throws Exception { + given(service.getProduct("p1")).willReturn(product()); + given(screeningService.findOrScreen("p1")).willReturn(Optional.of(screening())); + + mockMvc.perform(get("/admin/products/p1/workflow").param("step", "functional")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("닥터디퍼런트131모이스처라이저"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("확인 필요"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("저장하고 자동 조회"))); + } + + @Test + @DisplayName("판정이 없어도 화면은 뜬다 — 한글 이름 입력 칸만 보인다") + void rendersWithoutScreening() throws Exception { + given(service.getProduct("p1")).willReturn(product()); + given(screeningService.findOrScreen("p1")).willReturn(Optional.empty()); + + mockMvc.perform(get("/admin/products/p1/workflow").param("step", "functional")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("저장하고 자동 조회"))); + } + + private ProductResult product() { + return new ProductResult("p1", null, "Dr.different 311 Moisturizer", "311 모이스처라이저", + "닥터디퍼런트", "moisturizers", null, BigDecimal.ZERO, null, null, 0, + BigDecimal.ZERO, null, "ADMIN", List.of("정제수", "글리세린"), Map.of(), null, + List.of(), ProductStatus.INGREDIENTS_ADDED); + } + + private FunctionalScreening screening() { + MfdsItem item = new MfdsItem(MfdsSource.REPORT, "닥터디퍼런트131모이스처라이저", + "주식회사다른코스메틱스", "2", "피부의 주름개선에 도움을 준다.", null, null, + "제10조 제1항 제1호", "20230405", false); + MfdsCandidate candidate = new MfdsCandidate(item, 0.94, 0.94, + new ClaimReading(List.of(ProductFunctionalCategory.WRINKLE_IMPROVEMENT), false, true), + false, true); + return new FunctionalScreening("p1", ScreeningOutcome.NEEDS_REVIEW, List.of(), + List.of(candidate), -1, "LOW", "제품명 숫자가 달라 확정을 보류했습니다", 37, + FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); + } +} diff --git a/src/test/java/com/seoulection/admin/product/presentation/IngredientRowsViewTest.java b/src/test/java/com/seoulection/admin/product/presentation/IngredientRowsViewTest.java new file mode 100644 index 0000000..0612370 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/presentation/IngredientRowsViewTest.java @@ -0,0 +1,119 @@ +package com.seoulection.admin.product.presentation; + +import com.seoulection.admin.product.application.dto.ProductIngredientProperty; +import com.seoulection.admin.product.application.dto.ProductIngredientResult; +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.dto.PropertyDefinitionResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.presentation.controller.ProductController; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 성분 보완 화면(ingredient-rows 조각). + * + *

첫 번째 테스트가 특히 중요하다 — 특성이 하나라도 붙은 행에서만 터지던 버그를 막는다. + * 특성이 비어 있으면 SpEL 선택식이 원소를 한 번도 평가하지 않아 오류가 드러나지 않는다. 그래서 + * 특성이 없는 개발 DB에서는 멀쩡하다가, 실제로 값을 채우는 순간 화면이 흰 화면이 됐다 + * (Thymeleaf가 출력을 이미 내보낸 뒤라 에러 페이지조차 못 그린다). + */ +@WebMvcTest(ProductController.class) +class IngredientRowsViewTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + ProductService service; + + @MockitoBean + FunctionalScreeningService screeningService; + + private void stubProduct() { + given(service.getProduct("p1")).willReturn(new ProductResult( + "p1", null, "Goodal Serum", "구달 청귤 비타C 잡티 세럼", "구달", "treatments", null, + null, null, null, 0, BigDecimal.ZERO, null, "PIPELINE", + List.of("나이아신아마이드", "청귤껍질추출물"), Map.of(), null, + List.of(), ProductStatus.NEED_MANUAL_REVIEW)); + given(service.propertyDefinitions()).willReturn(List.of( + new PropertyDefinitionResult("PURITY", "순도", "NUMBER", "%", "원료 순도"), + new PropertyDefinitionResult("FORM", "형태", "TEXT", null, "분말/액상 등"))); + } + + private ProductIngredientResult matched(long id, String rawName, List properties) { + return new ProductIngredientResult(id, "00000000-0000-0000-0000-000000000107", rawName, (int) id, + new BigDecimal("2.0"), new BigDecimal("2.0"), "%", null, + "Niacinamide", "나이아신아마이드", properties); + } + + private ProductIngredientResult unmatched(long id, String rawName) { + return new ProductIngredientResult(id, null, rawName, (int) id, null, null, null, null, + null, null, List.of()); + } + + @Test + @DisplayName("특성이 채워진 행도 렌더링된다 — 값이 입력 칸에 다시 찍힌다") + void rendersRowWithExistingProperties() throws Exception { + stubProduct(); + given(service.getProductIngredients("p1")).willReturn(List.of( + matched(3, "나이아신아마이드", List.of(new ProductIngredientProperty( + "PURITY", "순도", "NUMBER", null, + new BigDecimal("98.5"), new BigDecimal("99.9"), "%", null))))); + + mockMvc.perform(get("/admin/products/p1/workflow").param("step", "ingredients")) + .andExpect(status().isOk()) + .andExpect(content().string(Matchers.containsString("98.5"))) + .andExpect(content().string(Matchers.containsString("나이아신아마이드"))); + } + + @Test + @DisplayName("사전에 없는 성분은 입력 폼 대신 '보완하지 않는 성분'으로만 보인다") + void unmatchedIngredientsGetNoForm() throws Exception { + stubProduct(); + given(service.getProductIngredients("p1")).willReturn(List.of( + matched(3, "나이아신아마이드", List.of()), + unmatched(7, "청귤껍질추출물"))); + + String html = mockMvc.perform(get("/admin/products/p1/workflow").param("step", "ingredients")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + // 폼은 사전 연결된 행 하나만. ("/workflow/ingredients/complete" 는 보완 완료 버튼이라 제외) + long forms = html.lines() + .filter(line -> line.contains("/ingredients/") && !line.contains("/workflow/ingredients/")) + .count(); + org.assertj.core.api.Assertions.assertThat(forms).isEqualTo(1); + org.assertj.core.api.Assertions.assertThat(html).contains("/ingredients/3"); + org.assertj.core.api.Assertions.assertThat(html).doesNotContain("/ingredients/7"); + // 그래도 사라지지는 않는다 — 왜 입력 칸이 없는지 화면에서 알 수 있어야 한다. + org.assertj.core.api.Assertions.assertThat(html).contains("사전에 없어 보완하지 않는 성분"); + org.assertj.core.api.Assertions.assertThat(html).contains("청귤껍질추출물"); + } + + @Test + @DisplayName("사전 연결된 성분이 하나도 없으면 그 이유를 알려 준다") + void explainsWhenNothingIsLinked() throws Exception { + stubProduct(); + given(service.getProductIngredients("p1")).willReturn(List.of(unmatched(7, "청귤껍질추출물"))); + + mockMvc.perform(get("/admin/products/p1/workflow").param("step", "ingredients")) + .andExpect(status().isOk()) + .andExpect(content().string(Matchers.containsString("보완할 항목이 없습니다"))); + } +} diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 1eff8f7..d837218 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -5,6 +5,7 @@ import com.seoulection.admin.product.application.service.ProductService; import com.seoulection.admin.product.domain.enums.ProductStage; import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; import com.seoulection.admin.product.presentation.controller.ProductController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -41,6 +42,10 @@ class ProductControllerTest { @MockitoBean ProductService service; + /** 컨트롤러가 자동 조회를 부르지만 이 테스트가 보는 건 검수 폼의 규칙이다 — 대역으로 둔다. */ + @MockitoBean + FunctionalScreeningService screeningService; + @BeforeEach void stubList() { given(service.getProducts(any(), any(), anyInt(), anyInt())) @@ -77,7 +82,7 @@ void register() throws Exception { .andExpect(redirectedUrl("/admin/products")) .andExpect(flash().attribute("successMessage", "제품을 등록했습니다.")); - then(service).should().register("시카 세럼", "서울렉션", "face masks", List.of()); + then(service).should().register("시카 세럼", null, "서울렉션", "face masks", List.of()); } @Test @@ -302,7 +307,7 @@ void explicitStepReopensPastStage() throws Exception { } private ProductResult reviewedProduct() { - return new ProductResult("abc", null, "시카 세럼", "서울렉션", "treatments", null, null, null, null, + return new ProductResult("abc", null, "시카 세럼", null, "서울렉션", "treatments", null, null, null, null, 0L, BigDecimal.ZERO, null, "ADMIN", List.of("Water"), null, null, List.of(), ProductStatus.READY_FOR_INCIAPI); } @@ -350,13 +355,13 @@ void saveIngredients() throws Exception { .param("ingredientResolution", "FOUND") .param("ingredientsText", "Water, Glycerin")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/admin/products?stage=ingredient-review")); + .andExpect(redirectedUrl("/admin/products/abc/workflow?step=ingredients")); then(service).should().reviewIngredients("abc", List.of("Water", "Glycerin"), false); } private ProductResult product() { - return new ProductResult("abc", null, "시카 세럼", "서울렉션", "treatments", null, null, null, null, + return new ProductResult("abc", null, "시카 세럼", null, "서울렉션", "treatments", null, null, null, null, 0L, BigDecimal.ZERO, null, "ADMIN", List.of("Water"), null, null, List.of(), ProductStatus.INGREDIENTS_ADDED); } diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java new file mode 100644 index 0000000..5f81c49 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java @@ -0,0 +1,59 @@ +package com.seoulection.admin.product.presentation; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.presentation.controller.ProductController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 상세 화면이 "수집 전" 상태의 빈 제품에서도 렌더링되는지 본다. */ +@WebMvcTest(ProductController.class) +class ProductDetailViewTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + ProductService service; + + @MockitoBean + FunctionalScreeningService screeningService; + + @Test + @DisplayName("갓 등록해 아무것도 채워지지 않은 PENDING 제품의 상세도 열린다") + void rendersBarePendingProduct() throws Exception { + given(service.getProduct("p1")).willReturn(new ProductResult( + "p1", null, "ㅁㄴㅇㄹ", null, "ㅁㄴㅇㄹ", "toners", null, + null, null, null, 0, BigDecimal.ZERO, null, null, + null, null, null, List.of(), ProductStatus.PENDING)); + + mockMvc.perform(get("/admin/products/p1")).andExpect(status().isOk()); + } + + @Test + @DisplayName("파이프라인이 채운 값이 다 있는 제품의 상세도 열린다") + void rendersFullyPopulatedProduct() throws Exception { + given(service.getProduct("p2")).willReturn(new ProductResult( + "p2", "B01ABC", "Goodal Serum", "구달 청귤 세럼", "구달", "treatments", "설명", + new BigDecimal("19900"), "https://img", "https://shop", 12, + new BigDecimal("0.3"), new BigDecimal("1.2"), "ADMIN", + List.of("정제수"), Map.of("k", "v"), java.time.Instant.now(), + List.of(), ProductStatus.COMPLETE)); + + mockMvc.perform(get("/admin/products/p2")).andExpect(status().isOk()); + } +}