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 클라이언트를 붙이면, 템플릿의 대상 수와 이력
+ * 영역만 실제 응답으로 교체하면 된다.
문의 대응용이다 — "푸시가 안 온다", "지운 기기로 계속 온다" 는 죽은 토큰이 원인인 경우가
+ * 많은데, 그것을 확인하고 끊을 자리가 없었다.
+ *
+ *
사용자 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)은 포트에 맡긴다.
+ *
+ *
조회 순서가 이 모양인 이유:
+ *
+ *
정규화한 브랜드+제품명으로 바로 검색 — 되는 건 여기서 끝난다(실측 35%).
+ *
등록명 후보로 재검색 — {@code 비타C→비타씨} 같은 표기 차이를 넘는다.
+ *
브랜드 전수 조회 — 등록명이 아무리 달라도 그 브랜드 목록 안에는 들어 있다.
+ * 회수율의 나머지 절반이 여기서 나온다. 덤으로 "브랜드 등록 0건"이라는 음성 근거와
+ * 업체명(ENTP_NAME) 기준값을 얻는다.
+ *
+ */
+@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 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 판정이 꺼져 있어 후보만 제시합니다");
+ }
+
+ /**
+ * 등록명에서 반복적으로 관찰되는 표기 차이 몇 가지. 정규화 전에 돌려야 한다.
+ *
+ *
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건 —
+ 성분 사전에 연결된 성분만 함량·특성을 입력합니다. 미연결 성분은 추천 계산에
+ 쓰이지 않아 지금 채워 둘 값이 없습니다.
+