From d6d58a2907babc076c55a68e9096fb658d9c19c0 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 03:39:27 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat(notification):=20=EC=88=98=EB=8F=99?= =?UTF-8?q?=20=ED=91=B8=EC=8B=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationAdminController.java | 20 ++++ src/main/resources/static/css/admin.css | 42 +++++++++ .../templates/fragments/sidebar.html | 6 ++ .../resources/templates/notifications.html | 91 +++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java create mode 100644 src/main/resources/templates/notifications.html diff --git a/src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java b/src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java new file mode 100644 index 0000000..8c614f4 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/presentation/NotificationAdminController.java @@ -0,0 +1,20 @@ +package com.seoulection.admin.notification.presentation; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * 수동 푸시 발송 화면의 진입점. + * + *

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

+ */ +@Controller +public class NotificationAdminController { + + @GetMapping("/admin/notifications") + public String page() { + return "notifications"; + } +} diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index ac51a2b..a9f608e 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; } diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/fragments/sidebar.html index d379d0e..f687ec7 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/fragments/sidebar.html @@ -63,6 +63,12 @@ 피부 점수 정책 + + + + + 푸시 알림 + diff --git a/src/main/resources/templates/notifications.html b/src/main/resources/templates/notifications.html new file mode 100644 index 0000000..ea44f74 --- /dev/null +++ b/src/main/resources/templates/notifications.html @@ -0,0 +1,91 @@ + + + + + + 푸시 알림 | MySeoulection Admin + + + +
+ +
+
+
+

푸시 알림

+

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

+
+
+ +
+

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

+ +
+
+
+

새 수동 발송

+

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

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

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

+
+
+ + +

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

+
+
+ + +

최대 50자

+
+
+ + +

최대 180자

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

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

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

발송 결과

+

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

+
+
+
+ 아직 표시할 발송 이력이 없습니다. notification-service 연동 후 발송 결과가 이곳에 쌓입니다. +
+
+
+
+
+ + From ddade0f6df7ad3a3451d40d8ed873c9c8e261bb9 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 04:07:01 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix(admin):=20=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20=EC=84=B1=EB=B6=84=20=EC=8B=9C=EB=93=9C=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=8F=99=20=EC=8B=A4=ED=8C=A8=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/config/IngredientSeedConfiguration.java | 2 ++ src/main/resources/application.yml | 7 +++++++ 2 files changed, 9 insertions(+) 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..e81ff63 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 @@ -3,6 +3,7 @@ import com.seoulection.admin.ingredient.infrastructure.document.Ingredient; import com.seoulection.admin.ingredient.infrastructure.repository.IngredientPostgresRepository; import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +13,7 @@ @Configuration public class IngredientSeedConfiguration { @Bean + @ConditionalOnProperty(name = "admin.ingredient.seed.enabled", havingValue = "true") ApplicationRunner seedIngredients(IngredientPostgresRepository repository) { return args -> { if (!repository.findAll().isEmpty()) return; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 418a98b..e74d4db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,3 +19,10 @@ spring: server: port: ${SERVER_PORT:8081} + +# V1 ingredient 테이블이 있는 개발 DB에서만 명시적으로 켠다. 새 V2 로컬 DB에는 아직 이 +# 레거시 스키마가 없으므로, 시드가 관리자 앱의 기동 자체를 막아서는 안 된다. +admin: + ingredient: + seed: + enabled: ${ADMIN_INGREDIENT_SEED_ENABLED:false} From 54cc47031f68f7fccaf96758de005dfb7cd67d5a Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 04:13:16 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix(admin):=20=EC=84=B1=EB=B6=84=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=EB=A5=BC=20=ED=95=AD=EC=83=81=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/config/IngredientSeedConfiguration.java | 2 -- src/main/resources/application.yml | 7 ------- 2 files changed, 9 deletions(-) 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 e81ff63..4b473bb 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 @@ -3,7 +3,6 @@ import com.seoulection.admin.ingredient.infrastructure.document.Ingredient; import com.seoulection.admin.ingredient.infrastructure.repository.IngredientPostgresRepository; import org.springframework.boot.ApplicationRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,7 +12,6 @@ @Configuration public class IngredientSeedConfiguration { @Bean - @ConditionalOnProperty(name = "admin.ingredient.seed.enabled", havingValue = "true") ApplicationRunner seedIngredients(IngredientPostgresRepository repository) { return args -> { if (!repository.findAll().isEmpty()) return; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e74d4db..418a98b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,10 +19,3 @@ spring: server: port: ${SERVER_PORT:8081} - -# V1 ingredient 테이블이 있는 개발 DB에서만 명시적으로 켠다. 새 V2 로컬 DB에는 아직 이 -# 레거시 스키마가 없으므로, 시드가 관리자 앱의 기동 자체를 막아서는 안 된다. -admin: - ingredient: - seed: - enabled: ${ADMIN_INGREDIENT_SEED_ENABLED:false} From 4b9e02dcde8328032e1978ae59b2c91fbfa1b255 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:23:39 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(admin):=20=EC=A0=9C=ED=92=88=20?= =?UTF-8?q?=EC=84=B1=EB=B6=84=20=ED=8A=B9=EC=84=B1=EC=9D=84=20=EC=9E=84?= =?UTF-8?q?=EC=9D=98=20=ED=82=A4=EB=A1=9C=20=ED=99=95=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=9D=84=20=ED=8F=AC=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 특성 읽기·삭제·쓰기 세 곳에 property_key='PURITY' 가 박혀 있어 순도 외에는 아무것도 담을 수 없었다. 실제로 필요한 것은 "이 제품의 히알루론산은 저분자", "아줄렌 순도 99%" 처럼 성분·제품마다 다른 축이다. property_definition 에 정의된 어떤 키든 받도록 일반화한다 — 수치형은 min/max, 범주형은 valueText. ## 화면 - 워크플로 1단계에 한글 이름 입력(Mongo products.name_ko). 전성분과 같은 폼이라 저장이 한 번이다. 비워 두면 유지한다 — 지우려는 의도와 구분할 수 없다. - 상세에 "성분별 보완" 섹션 신설. productIngredients 를 컨트롤러가 모델에 넣고 있었는데 템플릿이 렌더링하지 않아 행별 편집 UI 가 아예 없었다. 사전 연결 배지, 미연결 건수, 함량, 특성 입력을 한 자리에서 처리한다. ## 포트 ProductIngredientPort + JDBC/API 두 어댑터. 전환은 설정 한 줄이다. admin.product-ingredient.source = jdbc(기본) | api API 어댑터의 propertyDefinitions() 는 일부러 UnsupportedOperationException 을 던진다. V2 에 정의 API 가 없어 빈 목록을 주면 화면에 특성 칸이 안 뜨는데, 그것이 "고장"이 아니라 "입력할 게 없음"으로 보여 원인을 찾기 어렵다. ## 곁들여 nameKo 추가가 워킹트리에 미완성으로 있어 테스트 컴파일이 깨져 있었다. 인자를 맞췄다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../dto/ProductIngredientProperty.java | 36 +++++ .../dto/ProductIngredientResult.java | 29 +++++ .../application/dto/ProductResult.java | 2 + .../dto/PropertyDefinitionResult.java | 14 ++ .../port/ProductIngredientPort.java | 44 +++++++ .../application/service/ProductService.java | 28 +++- .../admin/product/domain/entity/Product.java | 13 +- .../document/ProductDocument.java | 4 + .../ApiProductIngredientAdapter.java | 106 +++++++++++++++ .../JdbcProductIngredientAdapter.java | 123 ++++++++++++++++++ .../ProductIngredientPostgresRepository.java | 32 ----- .../controller/ProductController.java | 68 ++++++++++ .../dto/ProductRegisterRequest.java | 3 + .../resources/templates/product-detail.html | 94 +++++++++++++ .../resources/templates/product-workflow.html | 6 + .../presentation/ProductControllerTest.java | 4 +- 16 files changed, 569 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java create mode 100644 src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientResult.java create mode 100644 src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java create mode 100644 src/main/java/com/seoulection/admin/product/application/port/ProductIngredientPort.java create mode 100644 src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java create mode 100644 src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java delete mode 100644 src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductIngredientPostgresRepository.java 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..5997799 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java @@ -0,0 +1,36 @@ +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 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, valueText, valueMin, valueMax, valueUnit, notes); + } + + /** 화면에 한 줄로 보여줄 값. 범위면 "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..78e9ce4 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java @@ -0,0 +1,14 @@ +package com.seoulection.admin.product.application.dto; + +/** + * 특성 키의 정의. 화면이 "무엇을 입력할 수 있는가"를 이 목록으로 그린다. + * + *

{@code valueType} 이 NUMERIC/NUMERIC_RANGE 면 숫자 칸을, 그 외에는 텍스트 칸을 보여준다. + */ +public record PropertyDefinitionResult(String propertyKey, String displayNameKo, String valueType, + String valueUnit, String description) { + + public boolean numeric() { + return valueType != null && valueType.toUpperCase().startsWith("NUMERIC"); + } +} 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..c595b0d 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; } @@ -61,6 +63,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); 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..5ec7793 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; @@ -98,6 +100,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; } @@ -163,9 +166,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 +187,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 +207,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/infrastructure/document/ProductDocument.java b/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java index 897620d..1101c9f 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_ko") + 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..9f369fa --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java @@ -0,0 +1,106 @@ +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.http.client.SimpleClientHttpRequestFactory; +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( + @Value("${admin.product-service.base-url:http://product-service:8080}") String baseUrl, + @Value("${admin.product-service.service-key:}") String serviceKey) { + this.serviceKey = serviceKey; + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.client = RestClient.builder().baseUrl(baseUrl).requestFactory(factory).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 와 필드가 달라(matched* 가 없다) 따로 둔다. */ + private record IngredientRow(long id, String ingredientId, String rawName, Integer order, + BigDecimal concentrationMin, BigDecimal concentrationMax, String unit, + String notes, String source, List properties) { + + ProductIngredientResult toResult() { + // ⚠️ V2 는 사전의 이름을 함께 주지 않는다 → 매칭 여부만 ingredientId 로 판단한다. + // 이름까지 보여주려면 V2 응답에 inciName/displayNameKo 를 추가해야 한다. + return new ProductIngredientResult(id, ingredientId, rawName, order == null ? 0 : order, + concentrationMin, concentrationMax, unit, notes, null, null, + 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..7c77098 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java @@ -0,0 +1,123 @@ +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; } + + @Override + @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; + jdbc.update("insert into product_ingredient(product_id, ingredient_id, raw_name, inci_order, source) values (?, ?, ?, ?, ?)", + productId, matchIngredient(rawName), rawName, i + 1, source == null ? "ADMIN" : source); + } + } + + /** 원문 → 성분 사전 id. 표기가 흔들리므로(WATER/Water/Aqua) 대소문자를 무시하고 별칭까지 본다. */ + private String matchIngredient(String rawName) { + return 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); + } + + @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, + 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_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) { + int changed = jdbc.update("update product_ingredient set ingredient_id=?, concentration_min=?, concentration_max=?, concentration_unit=?, notes=? where id=? and product_id=?", + blank(ingredientId), 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..d58bce6 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 @@ -20,6 +20,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 { @@ -150,10 +153,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; @@ -192,6 +253,13 @@ public String workflowIngredients(@PathVariable String id, @ModelAttribute Produ "성분을 입력하거나, 찾지 못한 경우 '성분을 찾지 못함'을 선택해 주세요."); 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()); + } service.reviewIngredients(id, ingredients, ingredientNotFound); redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다."); return "redirect:/admin/products?stage=ingredient-review"; 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/resources/templates/product-detail.html b/src/main/resources/templates/product-detail.html index 4ca919e..2ca83f0 100644 --- a/src/main/resources/templates/product-detail.html +++ b/src/main/resources/templates/product-detail.html @@ -86,6 +86,100 @@

전성분< + +
+
+
+

성분별 보완

+

+ 사전 미연결 + 0건 — + 연결되지 않은 성분은 추천 계산에 쓰이지 않습니다. +

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

빈 칸은 저장하지 않습니다 — 값을 지우면 그 특성이 삭제됩니다.

+
+
+ 정의된 특성이 없습니다. 특성 정의에서 먼저 추가하세요. +
+ +
+ + +
+ + +
+
+
+
diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index 06310ed..349e2de 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -53,6 +53,12 @@

성분 정보를 어디서도 찾지 못한 경우에만 '찾지 못함'을 고르세요 — NOT_FOUND로 저장됩니다.

+
+ + +

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

+
diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 1eff8f7..3e5d6db 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -302,7 +302,7 @@ void explicitStepReopensPastStage() throws Exception { } private ProductResult reviewedProduct() { - return new ProductResult("abc", null, "시카 세럼", "서울렉션", "treatments", null, null, null, null, + return new ProductResult("abc", null, "시카 세럼", null, "서울렉션", "treatments", null, null, null, null, 0L, BigDecimal.ZERO, null, "ADMIN", List.of("Water"), null, null, List.of(), ProductStatus.READY_FOR_INCIAPI); } @@ -356,7 +356,7 @@ void saveIngredients() throws Exception { } private ProductResult product() { - return new ProductResult("abc", null, "시카 세럼", "서울렉션", "treatments", null, null, null, null, + return new ProductResult("abc", null, "시카 세럼", null, "서울렉션", "treatments", null, null, null, null, 0L, BigDecimal.ZERO, null, "ADMIN", List.of("Water"), null, null, List.of(), ProductStatus.INGREDIENTS_ADDED); } From ef0656b0512c33cc14476924a2849945e5662b86 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:27:24 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(admin):=20=EC=A0=9C=ED=92=88=20?= =?UTF-8?q?=EA=B2=80=EC=88=98=EB=A5=BC=204=EB=8B=A8=EA=B3=84=20=EB=A7=88?= =?UTF-8?q?=EB=B2=95=EC=82=AC=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 성분 → 함량 → 한글 이름 → 기능성 순서로 나눈다. 함량은 성분이 있어야 채울 수 있고, 기능성 확인은 성분을 보고 판단한다. 한글 이름은 성분과 무관하지만 기능성보다 가벼워 앞에 둔다. - 성분 행 편집 UI 를 fragments/ingredient-rows 조각으로 뺐다. 상세 화면과 워크플로 2단계가 같은 조각을 쓴다 — 한쪽만 고쳐 입력 규칙이 갈리는 것을 막는다. - 함량·특성은 행마다 따로 저장한다. 성분 30개를 한 폼으로 묶으면 하나만 고쳐도 전부 다시 보내야 하고, 중간에 틀리면 어디가 틀렸는지 찾기 어렵다. - 성분을 찾지 못한 제품은 채울 함량이 없으므로 함량 단계를 건너뛴다. - 단계 표시(step-trail)로 지금 어디이고 몇 개가 남았는지 보인다. ⚠️ List.of() 는 불변 리스트라 contains(null) 이 false 가 아니라 NPE 다. step 파라미터는 대개 없으므로 null 검사가 반드시 앞에 와야 한다(테스트로 확인). 한글 이름 저장은 1단계에서 3단계로 옮겼다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../controller/ProductController.java | 64 +++++++++--- .../templates/fragments/ingredient-rows.html | 99 +++++++++++++++++++ .../resources/templates/product-detail.html | 95 +----------------- .../resources/templates/product-workflow.html | 47 +++++++-- .../presentation/ProductControllerTest.java | 2 +- 5 files changed, 194 insertions(+), 113 deletions(-) create mode 100644 src/main/resources/templates/fragments/ingredient-rows.html 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 d58bce6..eafa7e9 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 @@ -253,16 +253,10 @@ public String workflowIngredients(@PathVariable String id, @ModelAttribute Produ "성분을 입력하거나, 찾지 못한 경우 '성분을 찾지 못함'을 선택해 주세요."); 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()); - } service.reviewIngredients(id, ingredients, ingredientNotFound); - redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다."); - return "redirect:/admin/products?stage=ingredient-review"; + redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다. 이어서 함량을 입력하세요."); + // 성분을 찾지 못한 제품은 채울 함량이 없다 → 함량 단계를 건너뛴다. + return "redirect:/admin/products/" + id + "/workflow?step=" + (ingredientNotFound ? "name" : "concentrations"); } /** 2단계 저장 — 식약처 기능성만. 저장 후 기능성 확인 큐로 돌아간다. */ @@ -286,6 +280,43 @@ public String workflowFunctions(@PathVariable String id, @ModelAttribute Product return "redirect:/admin/products?stage=functional-review"; } + /** + * 검수 단계 순서. 화면의 단계 표시와 "다음" 이동이 이 순서를 따른다. + * + *

순서가 이 모양인 이유: 함량은 성분이 있어야 채울 수 있고, 기능성 확인은 성분을 보고 + * 판단한다. 한글 이름은 성분과 무관하지만 기능성보다 가벼워 앞에 둔다. + */ + private static final List WORKFLOW_STEPS = List.of("ingredients", "concentrations", "name", "functional"); + + /** 다음 단계 경로. 마지막 단계면 null 이다. */ + private String nextStep(String current) { + int index = WORKFLOW_STEPS.indexOf(current); + return index < 0 || index + 1 >= WORKFLOW_STEPS.size() ? null : WORKFLOW_STEPS.get(index + 1); + } + + /** 2단계 — 함량·특성은 행마다 따로 저장한다(조각 안의 폼). 여기서는 다음 단계로만 넘긴다. */ + @PostMapping("/admin/products/{id}/workflow/concentrations") + public String workflowConcentrations(@PathVariable String id, RedirectAttributes redirectAttributes) { + redirectAttributes.addFlashAttribute("successMessage", "함량 입력을 마쳤습니다."); + return "redirect:/admin/products/" + id + "/workflow?step=name"; + } + + /** + * 3단계 — 제품 한글 이름. MongoDB {@code products.name_ko} 에 저장된다. + * + *

비워 두면 기존 값을 유지한다 — 지우려는 의도와 구분할 수 없어 덮어쓰지 않는다. + */ + @PostMapping("/admin/products/{id}/workflow/name") + public String workflowName(@PathVariable String id, @RequestParam(required = false) String nameKo, + RedirectAttributes redirectAttributes) { + if (nameKo != null && !nameKo.isBlank()) { + var current = service.getProduct(id); + service.updateBasicInfo(id, current.name(), nameKo.trim(), current.brand(), current.category()); + redirectAttributes.addFlashAttribute("successMessage", "한글 이름을 저장했습니다."); + } + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + /** * 검수 작업 화면. 어느 단계를 열지는 제품 상태가 정한다({@link ProductStatus#workflowStep()}). * step 파라미터는 이미 지나간 단계를 다시 여는 용도다 — 기능성 화면에서 "성분 수정"으로 @@ -309,14 +340,23 @@ 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 ("concentrations".equals(resolved)) { + model.addAttribute("productIngredients", service.getProductIngredients(id)); + model.addAttribute("propertyDefinitions", service.propertyDefinitions()); + } return "product-workflow"; } } 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..0bacf41 --- /dev/null +++ b/src/main/resources/templates/fragments/ingredient-rows.html @@ -0,0 +1,99 @@ + + + +

+
+
+

성분별 보완

+

+ 사전 미연결 + 0건 — + 연결되지 않은 성분은 추천 계산에 쓰이지 않습니다. +

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

빈 칸은 저장하지 않습니다 — 값을 지우면 그 특성이 삭제됩니다.

+
+
+ 정의된 특성이 없습니다. 특성 정의에서 먼저 추가하세요. +
+ +
+ + +
+ + +
+
+
+ diff --git a/src/main/resources/templates/product-detail.html b/src/main/resources/templates/product-detail.html index 2ca83f0..702a13b 100644 --- a/src/main/resources/templates/product-detail.html +++ b/src/main/resources/templates/product-detail.html @@ -86,99 +86,8 @@

전성분<

- -
-
-
-

성분별 보완

-

- 사전 미연결 - 0건 — - 연결되지 않은 성분은 추천 계산에 쓰이지 않습니다. -

-
- 성분 사전 -
- -
-
-
- 1 - 성분명 - 사전 연결 - 사전에 없음 - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- 이 제품에서의 특성 -
-
- - - -
- - - -
-
- - - -
-
-
-

빈 칸은 저장하지 않습니다 — 값을 지우면 그 특성이 삭제됩니다.

-
-
- 정의된 특성이 없습니다. 특성 정의에서 먼저 추가하세요. -
- -
- - -
- - -
-
-
+ +
diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index 349e2de..b50115d 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -13,7 +13,24 @@
-

+

+ 전성분 보완 + 성분 함량 입력 + 제품 한글 이름 + 식약처 기능성 확인 +

+ +
    +
  1. + + 1. 성분 입력 + 2. 함량 입력 + 3. 한글 이름 + 4. 기능성 여부 + +
  2. +
@@ -53,12 +70,6 @@

성분 정보를 어디서도 찾지 못한 경우에만 '찾지 못함'을 고르세요 — NOT_FOUND로 저장됩니다.

-
- - -

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

-
@@ -84,6 +95,28 @@

등록된 성분이 없습니다.

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

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

+
+
+ +
+
diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 3e5d6db..9f93bd4 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -350,7 +350,7 @@ void saveIngredients() throws Exception { .param("ingredientResolution", "FOUND") .param("ingredientsText", "Water, Glycerin")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/admin/products?stage=ingredient-review")); + .andExpect(redirectedUrl("/admin/products/abc/workflow?step=concentrations")); then(service).should().reviewIngredients("abc", List.of("Water", "Glycerin"), false); } From d84e55606c306c1e3669110d81b8fb816cb6270c Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:30:12 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(admin):=20=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A1=B0=ED=9A=8C=C2=B7=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 푸시 문의 대응용이다. "푸시가 안 온다", "지운 기기로 계속 온다"는 죽은 토큰이 원인인 경우가 많은데 확인하고 끊을 자리가 없었다. - 사용자 ID 로만 조회한다. 토큰 역조회를 두지 않는 이유: 어드민이 토큰을 알 방법이 없고, 입력받는 화면을 두면 그 값이 로그·브라우저 기록에 남는다. - 토큰은 앞 6자 + 뒤 4자만 보여 준다. 전문이 있으면 그것만으로 발송할 수 있다. - userId 없이 들어오면 조회하지 않는다 — 전체 기기 목록은 볼 이유가 없고 양도 많다. - 해제는 확인 모달을 거친다(되돌리려면 사용자가 앱에서 알림을 다시 켜야 한다). NotificationDevicePort 로 감싸 둔다 — ProductIngredientPort 와 같은 이유다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../application/NotificationDevicePort.java | 20 +++++ .../ApiNotificationDeviceAdapter.java | 50 ++++++++++++ .../NotificationDeviceController.java | 49 ++++++++++++ .../templates/fragments/sidebar.html | 6 ++ .../templates/notification-devices.html | 80 +++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 src/main/java/com/seoulection/admin/notification/application/NotificationDevicePort.java create mode 100644 src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java create mode 100644 src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java create mode 100644 src/main/resources/templates/notification-devices.html 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/infrastructure/ApiNotificationDeviceAdapter.java b/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java new file mode 100644 index 0000000..19c71d7 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java @@ -0,0 +1,50 @@ +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.http.client.SimpleClientHttpRequestFactory; +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( + @Value("${admin.notification-service.base-url:http://notification-service:8080}") String baseUrl, + @Value("${admin.notification-service.service-key:}") String serviceKey) { + this.serviceKey = serviceKey; + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.client = RestClient.builder().baseUrl(baseUrl).requestFactory(factory).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/NotificationDeviceController.java b/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java new file mode 100644 index 0000000..a9a68f5 --- /dev/null +++ b/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java @@ -0,0 +1,49 @@ +package com.seoulection.admin.notification.presentation; + +import com.seoulection.admin.notification.application.NotificationDevicePort; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.List; + +/** + * 기기 토큰 관리 화면. + * + *

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

사용자 ID 로만 조회한다. 토큰으로 역조회하지 않는 이유: 어드민이 토큰을 알 방법이 없고, + * 토큰을 입력받는 화면을 두면 그 값이 로그·브라우저 기록에 남는다. + */ +@Controller +public class NotificationDeviceController { + + private final NotificationDevicePort devicePort; + + public NotificationDeviceController(NotificationDevicePort devicePort) { + this.devicePort = devicePort; + } + + @GetMapping("/admin/notifications/devices") + public String page(@RequestParam(required = false) Long userId, Model model) { + model.addAttribute("userId", userId); + // userId 가 없으면 조회하지 않는다 — 전체 기기 목록은 어드민이 볼 이유가 없고 양도 많다. + model.addAttribute("devices", userId == null ? List.of() : devicePort.activeOf(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) { + devicePort.revoke(registrationId); + redirectAttributes.addFlashAttribute("successMessage", + "기기 등록을 해제했습니다. 이 기기로는 더 이상 발송되지 않습니다."); + return "redirect:/admin/notifications/devices?userId=" + userId; + } +} diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/fragments/sidebar.html index f687ec7..8538f91 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/fragments/sidebar.html @@ -69,6 +69,12 @@ 푸시 알림 + + + + + 기기 토큰 +

diff --git a/src/main/resources/templates/notification-devices.html b/src/main/resources/templates/notification-devices.html new file mode 100644 index 0000000..80779eb --- /dev/null +++ b/src/main/resources/templates/notification-devices.html @@ -0,0 +1,80 @@ + + + + + + 기기 토큰 관리 | MySeoulection Admin + + + +
+ +
+
+
+

기기 토큰 관리

+

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

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

사용자 기기 조회

+

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

+
+
+ +
+
+ + +

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

+
+
+ + +
+ +
+
+
+

등록된 기기0

+

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

+
+
+ + + + + + + + + + + + +
플랫폼토큰해제
IOSabc123…7f9d +
+ + +
+
+ +
+ 살아 있는 기기 등록이 없습니다. 앱에서 알림을 한 번 켜야 등록됩니다. +
+
+
+
+
+ + From 33f48e7a1753931c48e40e8461dce5505061baa3 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:33:00 +0900 Subject: [PATCH 07/16] =?UTF-8?q?test(admin):=20API=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0=20=EC=9D=91=EB=8B=B5=20=EB=A7=A4=ED=95=91=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20+=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B3=84=EC=B8=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 계약 테스트 두 어댑터를 실제로 나란히 돌리려면 Postgres 와 product-service 가 둘 다 떠 있어야 한다. 그런데 실제로 깨지는 지점은 대부분 그게 아니라 필드 이름 어긋남이다 — V2 가 응답 필드를 바꾸면 어댑터에서 조용히 null 이 되고 화면에는 빈 칸으로만 보인다. HTTP 를 흉내 내는 것만으로 그 어긋남을 잡는다. 실제로 잡았다: V2 응답에 inciName/displayNameKo 를 추가했는데 어댑터가 버리고 있었다. ## 타임아웃 설정 위치 어댑터가 requestFactory() 를 부르면 MockRestServiceServer 가 붙여 둔 팩토리를 덮어써서 흉내 내려던 요청이 진짜로 나간다(실측). 타임아웃은 AdminHttpClientConfig 로 옮기고 어댑터는 baseUrl 만 정한다. 🔴 타임아웃 자체를 빼면 안 된다 — 기본값은 무한 대기라 호출 대상이 멈추면 어드민 화면도 함께 멈춘다. ## 계층 NotificationDeviceController 가 포트를 직접 잡고 있었다. 다른 도메인은 전부 서비스를 거치는데 여기만 어긋났다. NotificationDeviceService 를 넣는다 — 지금은 넘기기만 하지만 사용자 ID 검증이나 해제 사유 기록 같은 규칙이 생기면 그 자리가 필요하다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../admin/common/AdminHttpClientConfig.java | 27 ++++++ .../NotificationDeviceService.java | 36 +++++++ .../ApiNotificationDeviceAdapter.java | 7 +- .../NotificationDeviceController.java | 14 ++- .../ApiProductIngredientAdapter.java | 23 +++-- .../ApiProductIngredientAdapterTest.java | 97 +++++++++++++++++++ 6 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/seoulection/admin/common/AdminHttpClientConfig.java create mode 100644 src/main/java/com/seoulection/admin/notification/application/NotificationDeviceService.java create mode 100644 src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java 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/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 index 19c71d7..1ec0216 100644 --- a/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java +++ b/src/main/java/com/seoulection/admin/notification/infrastructure/ApiNotificationDeviceAdapter.java @@ -3,7 +3,6 @@ import com.seoulection.admin.notification.application.NotificationDevicePort; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -22,13 +21,11 @@ public class ApiNotificationDeviceAdapter implements NotificationDevicePort { 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; - var factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(5000); - factory.setReadTimeout(10000); - this.client = RestClient.builder().baseUrl(baseUrl).requestFactory(factory).build(); + this.client = builder.baseUrl(baseUrl).build(); } @Override diff --git a/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java b/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java index a9a68f5..945091c 100644 --- a/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java +++ b/src/main/java/com/seoulection/admin/notification/presentation/NotificationDeviceController.java @@ -1,6 +1,6 @@ package com.seoulection.admin.notification.presentation; -import com.seoulection.admin.notification.application.NotificationDevicePort; +import com.seoulection.admin.notification.application.NotificationDeviceService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; -import java.util.List; /** * 기기 토큰 관리 화면. @@ -23,17 +22,16 @@ @Controller public class NotificationDeviceController { - private final NotificationDevicePort devicePort; + private final NotificationDeviceService service; - public NotificationDeviceController(NotificationDevicePort devicePort) { - this.devicePort = devicePort; + 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); - // userId 가 없으면 조회하지 않는다 — 전체 기기 목록은 어드민이 볼 이유가 없고 양도 많다. - model.addAttribute("devices", userId == null ? List.of() : devicePort.activeOf(userId)); + model.addAttribute("devices", service.activeDevices(userId)); model.addAttribute("searched", userId != null); return "notification-devices"; } @@ -41,7 +39,7 @@ public String page(@RequestParam(required = false) Long userId, Model model) { @PostMapping("/admin/notifications/devices/{registrationId}/revoke") public String revoke(@PathVariable Long registrationId, @RequestParam Long userId, RedirectAttributes redirectAttributes) { - devicePort.revoke(registrationId); + service.revoke(registrationId); redirectAttributes.addFlashAttribute("successMessage", "기기 등록을 해제했습니다. 이 기기로는 더 이상 발송되지 않습니다."); return "redirect:/admin/notifications/devices?userId=" + userId; 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 index 9f369fa..484a05b 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ApiProductIngredientAdapter.java @@ -8,7 +8,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Repository; import org.springframework.web.client.RestClient; @@ -34,13 +33,13 @@ public class ApiProductIngredientAdapter implements ProductIngredientPort { 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; - var factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(5000); - factory.setReadTimeout(10000); - this.client = RestClient.builder().baseUrl(baseUrl).requestFactory(factory).build(); + // Builder 를 주입받는 이유는 테스트다 — MockRestServiceServer 가 이 빌더에 붙어야 + // 실제 HTTP 없이 응답 매핑을 검증할 수 있다. 타임아웃은 AdminHttpClientConfig 가 건다. + this.client = builder.baseUrl(baseUrl).build(); } @Override @@ -90,16 +89,20 @@ public List propertyDefinitions() { private record ReviewBody(String ingredientId, BigDecimal concentrationMin, BigDecimal concentrationMax, String unit, String notes, List properties) {} - /** V2 응답 모양. 어드민 DTO 와 필드가 달라(matched* 가 없다) 따로 둔다. */ + /** + * 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, List properties) { + String notes, String source, String inciName, String displayNameKo, + List properties) { ProductIngredientResult toResult() { - // ⚠️ V2 는 사전의 이름을 함께 주지 않는다 → 매칭 여부만 ingredientId 로 판단한다. - // 이름까지 보여주려면 V2 응답에 inciName/displayNameKo 를 추가해야 한다. return new ProductIngredientResult(id, ingredientId, rawName, order == null ? 0 : order, - concentrationMin, concentrationMax, unit, notes, null, null, + concentrationMin, concentrationMax, unit, notes, inciName, displayNameKo, properties == null ? List.of() : properties); } } diff --git a/src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java b/src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java new file mode 100644 index 0000000..3a7b12c --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/infrastructure/ApiProductIngredientAdapterTest.java @@ -0,0 +1,97 @@ +package com.seoulection.admin.product.infrastructure; + +import com.seoulection.admin.product.application.dto.ProductIngredientResult; +import com.seoulection.admin.product.infrastructure.repository.ApiProductIngredientAdapter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * API 어댑터가 product-service 응답을 JDBC 어댑터와 같은 모양으로 옮기는지 본다. + * + *

왜 이 테스트인가: 두 어댑터를 실제로 나란히 돌리려면 Postgres 와 product-service 가 둘 다 + * 떠 있어야 한다. 그런데 실제로 깨지는 지점은 대부분 그게 아니라 필드 이름 어긋남이다 — + * V2 가 응답 필드를 바꾸면 여기서 조용히 null 이 되고, 화면에는 빈 칸으로만 보인다. + * 그 어긋남은 HTTP 를 흉내 내는 것만으로 잡을 수 있다. + */ +class ApiProductIngredientAdapterTest { + + private static final String BASE_URL = "http://product-service:8080"; + + @Test + @DisplayName("★ product-service 응답을 화면이 쓰는 결과로 옮긴다 — 필드 이름이 어긋나면 여기서 걸린다") + void mapsResponseToResult() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + var adapter = new ApiProductIngredientAdapter(builder, BASE_URL, "secret"); + + server.expect(requestTo(BASE_URL + "/internal/admin/v1/products/p1/ingredients")) + .andExpect(header("X-Service-Key", "secret")) + .andRespond(withSuccess(""" + [ + {"id": 7, "ingredientId": "ing-1", "rawName": "Sodium Hyaluronate", "order": 1, + "concentrationMin": 1.0, "concentrationMax": 2.5, "unit": "%", + "notes": "저분자", "source": "ADMIN", + "inciName": "SODIUM HYALURONATE", "displayNameKo": "소듐하이알루로네이트", + "properties": [ + {"propertyKey": "PURITY", "displayNameKo": "순도", "valueText": null, + "valueMin": 99, "valueMax": 99, "valueUnit": "%", "notes": null} + ]}, + {"id": 8, "ingredientId": null, "rawName": "Unknown Extract", "order": 2, + "concentrationMin": null, "concentrationMax": null, "unit": null, + "notes": null, "source": "PIPELINE", + "inciName": null, "displayNameKo": null, "properties": []} + ] + """, MediaType.APPLICATION_JSON)); + + List rows = adapter.findByProductId("p1"); + + assertThat(rows).hasSize(2); + + ProductIngredientResult matched = rows.get(0); + assertThat(matched.id()).isEqualTo(7); + assertThat(matched.matched()).isTrue(); + assertThat(matched.rawName()).isEqualTo("Sodium Hyaluronate"); + assertThat(matched.order()).isEqualTo(1); + assertThat(matched.concentrationMin()).isEqualByComparingTo(BigDecimal.ONE); + assertThat(matched.unit()).isEqualTo("%"); + // 사전 이름이 오면 화면은 한글명을 쓴다 — JDBC 어댑터와 같은 규칙이다. + assertThat(matched.displayName()).isEqualTo("소듐하이알루로네이트"); + assertThat(matched.properties()).singleElement() + .satisfies(property -> { + assertThat(property.propertyKey()).isEqualTo("PURITY"); + assertThat(property.display()).isEqualTo("99 %"); + }); + + // 사전에 없는 성분은 오류가 아니다. 원문을 그대로 보여 준다. + ProductIngredientResult unmatched = rows.get(1); + assertThat(unmatched.matched()).isFalse(); + assertThat(unmatched.displayName()).isEqualTo("Unknown Extract"); + assertThat(unmatched.properties()).isEmpty(); + + server.verify(); + } + + @Test + @DisplayName("특성 정의는 V2에 API가 없다 — 빈 목록 대신 명시적으로 실패한다") + void propertyDefinitionsFailsLoudly() { + RestClient.Builder builder = RestClient.builder(); + var adapter = new ApiProductIngredientAdapter(builder, BASE_URL, "secret"); + + // 빈 목록을 주면 화면에 특성 칸이 안 뜨는데 그게 "고장"이 아니라 "입력할 게 없음"으로 + // 보인다. 원인을 찾기 어려우므로 여기서 터뜨린다. + assertThatThrownBy(adapter::propertyDefinitions) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("property-definitions"); + } +} From 44fd2d2a257170a427e5542f31734b152681ee1d Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:38:43 +0900 Subject: [PATCH 08/16] =?UTF-8?q?refactor(admin):=20=EA=B2=80=EC=88=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EA=B8=B0=EC=A1=B4=202=ED=83=AD?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 성분 보완 탭에서 성분과 함량을, 기능성 확인 탭에서 한글 이름과 기능성을 입력한다. 4단계 마법사로 쪼갰던 것을 되돌린다 — 두 탭은 근거 자료가 달라서 나뉜 것이지, 입력 항목마다 나누면 저장 버튼만 늘고 어드민이 같은 제품을 네 번 열게 된다. 성분을 저장하면 같은 탭에 남아 방금 저장된 목록에 함량을 채우게 한다. 화면을 옮기면 맥락이 끊긴다. 성분을 찾지 못한 경우에만 큐로 돌아간다(채울 함량이 없다). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- docs/functional-screening.md | 238 ++++++++++++++++++ .../application/port/CandidateVerdict.java | 25 ++ .../port/FunctionalScreeningRepository.java | 12 + .../application/port/MfdsCatalogPort.java | 26 ++ .../port/ProductNameResolverPort.java | 40 +++ .../application/port/ScreeningTarget.java | 35 +++ .../functional/domain/ClaimReading.java | 34 +++ .../functional/domain/FunctionalClaims.java | 69 +++++ .../domain/FunctionalScreening.java | 49 ++++ .../product/functional/domain/ItemName.java | 87 +++++++ .../functional/domain/MfdsCandidate.java | 44 ++++ .../product/functional/domain/MfdsItem.java | 30 +++ .../product/functional/domain/MfdsSource.java | 20 ++ .../functional/domain/ScreeningOutcome.java | 32 +++ .../controller/ProductController.java | 55 ++-- .../resources/templates/product-workflow.html | 53 +--- .../presentation/ProductControllerTest.java | 2 +- 17 files changed, 774 insertions(+), 77 deletions(-) create mode 100644 docs/functional-screening.md create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/port/CandidateVerdict.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/port/FunctionalScreeningRepository.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/port/MfdsCatalogPort.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/port/ProductNameResolverPort.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/port/ScreeningTarget.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/ClaimReading.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/FunctionalClaims.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/FunctionalScreening.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/MfdsItem.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/MfdsSource.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/domain/ScreeningOutcome.java diff --git a/docs/functional-screening.md b/docs/functional-screening.md new file mode 100644 index 0000000..ef43acf --- /dev/null +++ b/docs/functional-screening.md @@ -0,0 +1,238 @@ +# 기능성 검수 자동화 (Functional Screening) + +지금 어드민의 "2 기능성 확인" 큐는 100% 수동이다. 사람이 의약품안전나라를 직접 검색해 +기능성 여부를 판단하고 유형을 고른다. 이 문서는 그 판단을 자동으로 시도하고, **자동으로 +확신할 수 없을 때만 사람에게 넘기는** 구조를 설명한다. + +목표는 "수동을 없애는 것"이 아니라 **수동 큐를 줄이는 것**이다. 기능성은 규제 정보라서 +틀린 자동 확정은 비어 있는 큐보다 훨씬 비싸다. + +--- + +## 0. 먼저, 처음 구상과 달라진 점 (실측) + +원래 구상은 "한글 제품명 → LLM이 실제 법인명(주식회사 ○○)을 찾음 → **그 회사명으로** +안전나라 API 호출"이었다. 실제로 두 API를 찔러 확인한 결과 **이 경로는 성립하지 않는다.** + +``` +GET .../getRptPrdlstInq?...&entp_name=클리오 → totalCount 195220 (= 전체 건수) +GET .../getRptPrdlstInq?...&entpName=클리오 → totalCount 195220 +GET .../getRptPrdlstInq?...&ENTP_NAME=클리오 → totalCount 195220 +GET .../getRptPrdlstInq?...&bizrno=1198134685 → totalCount 195220 +``` + +`item_name` **말고는 어떤 파라미터도 필터로 동작하지 않는다.** 업체명·사업자번호·기능성 +구분 모두 무시되고 전체 목록이 그대로 나온다. 즉 **법인명으로는 조회 자체가 불가능하다.** + +그래서 법인명의 역할을 바꿨다. + +- 조회 키로 쓰지 않는다 (불가능). +- 대신 **응답에 들어 있는 `ENTP_NAME`을 검증 신호로 쓴다.** 안전나라가 직접 알려주는 + 값이므로 LLM이 기억해 낸 법인명보다 정확하다. LLM이 "구달은 클리오"라고 답할 필요가 + 없다 — `item_name=구달`로 193건을 받아 보면 전부 `(주)클리오`라고 API가 말해 준다. + +**LLM이 실제로 필요한 자리는 따로 있다.** 아래 3절. + +--- + +## 1. 왜 지금 "상품 이름으로만 검색하면 안 나오는가" + +안전나라의 `ITEM_NAME`은 **유통명이 아니라 등록명**이고, 공백·기호가 전부 제거된 형태다. + +| 우리가 가진 이름 | 안전나라 등록명 | +|---|---| +| 구달 청귤 비타C 잡티 세럼 | `구달청귤비타씨잡티세럼` | +| 폴라초이스 10% 나이아신아마이드 부스터 | `폴라초이스10퍼센트나이아신아마이드부스터` | +| 뉴트로지나 딥클린 포밍 클렌저 | `뉴트로지나딥클린아크네포밍클렌저(보)` | +| 달바 워터풀 톤업 선크림 [SPF50+/PA++++] | `달바워터풀톤업선크림` | + +`비타C→비타씨`, `10%→10퍼센트`, 중간에 끼어드는 `아크네`, 뒤에 붙는 `(보)`. 공백만 지우는 +정규화로는 못 넘는 벽이고, 이게 지금 검색이 안 되는 이유의 대부분이다. + +기존 파이썬 스크립트(`crawlers/glowpick/check_functional_cosmetics.py`) 결과로 실측하면: + +``` +제품 83건 + 후보 0건 48건 (58%) ← 검색 실패 + 최고 유사도 ≥ 0.85 29건 (35%) ← 자동 처리 가능 +``` + +**58%가 검색 실패**다. 이걸 그대로 "기능성 아님"으로 확정하면 안 된다. + +--- + +## 2. 핵심 전략: 브랜드 전수 조회 + +`item_name`은 부분 일치(LIKE)로 동작한다. 제품명 전체가 아니라 **브랜드 한글명만** 넣으면 +그 브랜드의 등록 목록 전체가 나온다. 그 안에서 제품을 찾으면 등록명 표기 차이를 통과한다. + +미매칭 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건 → 브랜드 자체가 DB에 없음 ⇒ 기능성 아님 근거 +[꼬달리] 뷰티 엘릭시르 brand총 0건 → 브랜드 자체가 DB에 없음 ⇒ 기능성 아님 근거 +``` + +세 가지를 동시에 알려 준다. + +1. **회수율이 올라간다** — 직접 검색이 실패한 제품이 브랜드 전수에서 발견된다. +2. **유사도만 믿으면 안 된다** — `311 vs 131` (0.94), `77 vs 70` (0.64)처럼 + **숫자 한 자리가 다른 제품**이 높은 점수를 받는다. 이건 규칙으로 막아야 한다. +3. **"브랜드 총 0건"은 강한 음성 근거다** — 해외 브랜드가 국내 기능성 등록을 하지 않은 + 경우이고, 이때만 "기능성 아님"을 자동 확정할 근거가 생긴다. + +--- + +## 3. LLM이 들어가는 자리 (세 군데뿐) + +LLM은 **기능성을 판단하지 않는다.** 기능성 유형은 오직 안전나라 응답 필드에서만 나온다. +LLM은 "무엇을 검색할지"와 "이 후보가 같은 제품인지"만 답한다. 규제 데이터를 모델이 +지어내지 못하게 하는 경계다. + +| 자리 | 입력 | 출력 | 왜 LLM인가 | +|---|---|---|---| +| ① 브랜드 한글 표기 | `Goodal`, `d'Alba`, `Round Lab` | `구달`, `달바`, `라운드랩` | 음차 표기는 규칙으로 못 만든다. 브랜드당 **1회**만 부르고 캐시한다 | +| ② 등록명 후보 생성 | `구달 청귤 비타C 잡티 세럼` | `구달청귤비타씨잡티세럼`, `구달청귤비타C잡티세럼` … | `비타C→비타씨`, `10%→10퍼센트` 같은 표기 변환 | +| ③ 후보 판정 | 제품 정보 + 안전나라 후보 N건 | 몇 번이 같은 제품인가 / 없음 + 신뢰도 | `311 vs 131`은 유사도로는 못 거르고 의미로 걸러야 한다 | + +①은 브랜드 단위 캐시라 호출이 거의 없고, ②③은 제품당 최대 2회다. 후보가 0건이거나 +정규화만으로 완전 일치하면 ③은 아예 부르지 않는다. + +> 웹 검색이 필요한가: 기본은 아니오. 법인명은 API 응답(`ENTP_NAME`)이 알려주므로 검색할 +> 이유가 없다. 한글 표기를 도저히 모르는 브랜드용으로 `llm.web-search=true` 토글만 남겨 둔다. + +--- + +## 4. 기능성 유형은 어디서 나오는가 + +`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'`(취하)인 행은 후보에서 제외한다. + +--- + +## 5. 판정 등급과 폴백 + +``` + ┌─────────────────────────────────────────┐ + INGREDIENTS_ADDED ─┤ 1. 직접 검색 (정규화된 브랜드+제품명) │ + (기능성 확인 큐) │ 2. LLM 등록명 후보로 재검색 │ + │ 3. 브랜드 전수 조회 후 그 안에서 매칭 │ + │ 4. 취하 건 제외 · 점수 정렬 · 상위 5건 │ + └───────────────┬─────────────────────────┘ + │ + ┌────────────────────────────┼────────────────────────────┐ + ▼ ▼ ▼ + AUTO_CONFIRMED NEEDS_REVIEW NOT_MATCHED + 자동 확정 후보는 있는데 애매 후보 0건 + → READY_FOR_INCIAPI → 어드민 큐 (후보 제시) → 어드민 큐 + (+ 브랜드 0건이면 + AUTO_NONE 후보) +``` + +**AUTO_CONFIRMED 조건 — 전부 만족해야 한다.** + +- 최고 후보 점수 ≥ `auto-threshold`(기본 0.95), 또는 LLM 판정이 `HIGH` +- **숫자 토큰 집합이 완전히 일치** (`311 ≠ 131`, `77 ≠ 70`) ← 실측에서 나온 하드 룰 +- `ENTP_NAME`이 같은 브랜드의 다른 등록 건과 일치하거나, 등록명이 브랜드 토큰으로 시작 +- 4절 규칙으로 **기능성 유형이 도출됨** (도출 불가면 확정하지 않는다) + +**AUTO_NONE("기능성 아님" 자동 확정)** — 기본 **꺼져 있다**(`auto-conclude-none: false`). +켤 경우에도 아래를 모두 만족할 때만 확정한다. + +- 브랜드 전수 조회가 정상 종료했고 그 브랜드의 등록 건이 **0건** +- 카테고리가 `sunscreens`가 아님 (선크림은 법적으로 기능성이어야 한다 — 미검색은 실패로 본다) +- 제품명에 `선/자차/톤업/미백/주름/브라이트닝/UV/SPF` 같은 기능성 암시 토큰이 없음 + +**실패는 조용히 넘어가지 않는다.** API 오류·타임아웃·LLM 오류는 `FAILED`로 남기고 사람 +큐에 그대로 둔다. 판정 못 한 것을 "기능성 아님"으로 접지 않는다. + +--- + +## 6. 데이터 · 상태 + +`ProductStatus`는 **건드리지 않는다.** 지금 설계대로 status는 "누가 움직일 차례인가"만 +말하고, 자동 판정의 내막은 새 컬렉션 `functional_screenings`(제품당 1건)가 담는다. + +``` +functional_screenings + _id = product_id + outcome AUTO_CONFIRMED | AUTO_NONE | NEEDS_REVIEW | NOT_MATCHED | FAILED + claims [WHITENING, ...] ← 안전나라에서 도출된 유형 + candidates[] { itemName, entpName, source, score, claims, reportDate, eeName, spf, pa } + selectedIndex 확정/제안된 후보 (없으면 -1) + confidence, reason 판정 근거 (LLM 사유 포함) + brandRegistryCount 브랜드 전수 조회 건수 (0이면 음성 근거) + decidedBy AUTO | ADMIN + engineVersion 판정 규칙 버전 — 규칙을 바꾸면 재판정 대상을 고를 수 있다 + screenedAt +``` + +`decidedBy`와 `engineVersion`을 남기는 이유: 규제 정보라서 "누가·어느 규칙으로 이렇게 +정했는가"를 나중에 되짚을 수 있어야 하고, 규칙을 고쳤을 때 재판정 범위를 고를 수 있어야 한다. + +--- + +## 7. 화면 + +기능성 단계(4단계) 폼 위에 **자동 조회 결과 카드**가 붙는다. + +- 후보 표: 등록명 · 업체명 · 보고일 · 도출된 유형 · 점수 — 각 행에 [이 후보로 채우기] +- 자동 판정 사유 한 줄 (`숫자 불일치로 자동 확정 보류: 311 vs 131`) +- [다시 조회] 버튼 + +**라디오는 절대 미리 선택하지 않는다.** 지금 폼이 기본값을 두지 않는 이유(확인 없이 저장 +버튼만 눌러도 "기능성 아님"이 사실로 기록되는 걸 막는 것)가 자동화에서도 그대로 유효하다. +자동이 채우는 건 유형 체크박스까지고, 최종 라디오는 사람이 누른다. + +--- + +## 8. 설정 + +```yaml +admin: + functional-screening: + enabled: true + mfds: + service-key: ${MFDS_SERVICE_KEY:} # data.go.kr 인코딩 키 + 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 # 브랜드 전수 상한 (2000건) + auto-threshold: 0.95 + candidate-threshold: 0.60 + auto-conclude-none: false # "기능성 아님" 자동 확정 (기본 꺼짐) + llm: + enabled: false # 켜면 ①②③ 자리에 Claude가 들어간다 + model: claude-opus-5 + web-search: false +``` + +`llm.enabled=false`면 규칙 기반 대체 구현이 그 자리를 메운다(정규화 변형만 생성, 후보 +판정은 점수로만). 즉 **LLM 없이도 동작하고**, 켜면 회수율이 올라간다. + +## 9. 단계적 도입 + +1. **그림자 모드** — `auto-conclude-none=false`, 자동 확정도 저장하지 않고 판정만 기록. + 어드민 화면엔 후보만 보여 준다. 사람 판단과 자동 판정을 몇 주 비교한다. +2. **자동 확정 켜기** — 1의 일치율이 충분하면 AUTO_CONFIRMED가 status를 전진시킨다. +3. **AUTO_NONE 켜기** — 브랜드 0건 케이스의 정확도가 확인된 뒤에만. 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..97b9d43 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/port/ScreeningTarget.java @@ -0,0 +1,35 @@ +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; + return prefix + displayName(); + } +} 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..8a2ab1e --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java @@ -0,0 +1,87 @@ +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()); + } + + /** + * 이름 안의 숫자 토큰. 자동 확정을 막는 하드 룰의 근거다. + * + *

실측에서 "닥터디퍼런트 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..e075e62 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java @@ -0,0 +1,44 @@ +package com.seoulection.admin.product.functional.domain; + +/** + * 점수가 매겨진 후보 한 건. 화면의 후보 표가 이 값을 그대로 그린다. + * + * @param numericMatch 숫자 토큰이 일치하는가. false면 점수와 무관하게 자동 확정하지 않는다. + * @param brandMatch 등록명이 브랜드로 시작하거나 업체명이 브랜드 등록 업체와 같은가. + */ +public record MfdsCandidate( + MfdsItem item, + double score, + 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()), + 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(); + } + + /** 자동 확정을 막은 이유. 화면에 그대로 보여 준다. */ + public String blockReason() { + if (!numericMatch) { + return "제품명 숫자가 다릅니다(" + ItemName.numericTokens(item.itemName()) + ") — 다른 제품일 수 있습니다"; + } + if (!brandMatch) { + return "등록 업체(" + item.entpName() + ")가 이 브랜드와 이어지지 않습니다"; + } + if (!claims.autoConfirmable()) { + return claims.reason(); + } + 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/presentation/controller/ProductController.java b/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java index eafa7e9..70167f9 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 @@ -254,9 +254,14 @@ public String workflowIngredients(@PathVariable String id, @ModelAttribute Produ return "redirect:/admin/products/" + id + "/workflow"; } service.reviewIngredients(id, ingredients, ingredientNotFound); + // 성분을 찾지 못했으면 채울 함량이 없으니 큐로 돌아간다. 찾았으면 같은 탭에 남아 + // 방금 저장된 성분 목록에 함량을 채우게 한다 — 화면을 옮기면 맥락이 끊긴다. + if (ingredientNotFound) { + redirectAttributes.addFlashAttribute("successMessage", "성분을 찾지 못함으로 저장했습니다."); + return "redirect:/admin/products?stage=ingredient-review"; + } redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다. 이어서 함량을 입력하세요."); - // 성분을 찾지 못한 제품은 채울 함량이 없다 → 함량 단계를 건너뛴다. - return "redirect:/admin/products/" + id + "/workflow?step=" + (ingredientNotFound ? "name" : "concentrations"); + return "redirect:/admin/products/" + id + "/workflow?step=ingredients"; } /** 2단계 저장 — 식약처 기능성만. 저장 후 기능성 확인 큐로 돌아간다. */ @@ -268,6 +273,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) { @@ -281,41 +293,12 @@ public String workflowFunctions(@PathVariable String id, @ModelAttribute Product } /** - * 검수 단계 순서. 화면의 단계 표시와 "다음" 이동이 이 순서를 따른다. - * - *

순서가 이 모양인 이유: 함량은 성분이 있어야 채울 수 있고, 기능성 확인은 성분을 보고 - * 판단한다. 한글 이름은 성분과 무관하지만 기능성보다 가벼워 앞에 둔다. - */ - private static final List WORKFLOW_STEPS = List.of("ingredients", "concentrations", "name", "functional"); - - /** 다음 단계 경로. 마지막 단계면 null 이다. */ - private String nextStep(String current) { - int index = WORKFLOW_STEPS.indexOf(current); - return index < 0 || index + 1 >= WORKFLOW_STEPS.size() ? null : WORKFLOW_STEPS.get(index + 1); - } - - /** 2단계 — 함량·특성은 행마다 따로 저장한다(조각 안의 폼). 여기서는 다음 단계로만 넘긴다. */ - @PostMapping("/admin/products/{id}/workflow/concentrations") - public String workflowConcentrations(@PathVariable String id, RedirectAttributes redirectAttributes) { - redirectAttributes.addFlashAttribute("successMessage", "함량 입력을 마쳤습니다."); - return "redirect:/admin/products/" + id + "/workflow?step=name"; - } - - /** - * 3단계 — 제품 한글 이름. MongoDB {@code products.name_ko} 에 저장된다. + * 검수 탭. 기존 2탭 구조를 지킨다 — 성분 보완(성분+함량)과 기능성 확인(한글 이름+기능성). * - *

비워 두면 기존 값을 유지한다 — 지우려는 의도와 구분할 수 없어 덮어쓰지 않는다. + *

탭을 더 쪼개지 않는 이유: 근거 자료가 다른 두 작업이라 나누는 것이지, 입력 항목마다 + * 나누면 저장 버튼만 늘고 어드민이 같은 제품을 네 번 열게 된다. */ - @PostMapping("/admin/products/{id}/workflow/name") - public String workflowName(@PathVariable String id, @RequestParam(required = false) String nameKo, - RedirectAttributes redirectAttributes) { - if (nameKo != null && !nameKo.isBlank()) { - var current = service.getProduct(id); - service.updateBasicInfo(id, current.name(), nameKo.trim(), current.brand(), current.category()); - redirectAttributes.addFlashAttribute("successMessage", "한글 이름을 저장했습니다."); - } - return "redirect:/admin/products/" + id + "/workflow?step=functional"; - } + private static final List WORKFLOW_STEPS = List.of("ingredients", "functional"); /** * 검수 작업 화면. 어느 단계를 열지는 제품 상태가 정한다({@link ProductStatus#workflowStep()}). @@ -353,7 +336,7 @@ public String workflowPage(@PathVariable String id, model.addAttribute("workflowStep", resolved); model.addAttribute("workflowSteps", WORKFLOW_STEPS); model.addAttribute("workflowStepIndex", WORKFLOW_STEPS.indexOf(resolved)); - if ("concentrations".equals(resolved)) { + if ("ingredients".equals(resolved)) { model.addAttribute("productIngredients", service.getProductIngredients(id)); model.addAttribute("propertyDefinitions", service.propertyDefinitions()); } diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index b50115d..cdcc900 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -13,24 +13,7 @@

-

- 전성분 보완 - 성분 함량 입력 - 제품 한글 이름 - 식약처 기능성 확인 -

- -
    -
  1. - - 1. 성분 입력 - 2. 함량 입력 - 3. 한글 이름 - 4. 기능성 여부 - -
  2. -
+

@@ -83,6 +66,12 @@

+ +
+
@@ -95,31 +84,15 @@

등록된 성분이 없습니다.

- -
-
-
- -
-
- -
-
-
- - -

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

-
-
- -
-
+
+ 제품 한글 이름 + +

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

+
의약품안전나라 조회 결과
diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 9f93bd4..7ea2efc 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -350,7 +350,7 @@ void saveIngredients() throws Exception { .param("ingredientResolution", "FOUND") .param("ingredientsText", "Water, Glycerin")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/admin/products/abc/workflow?step=concentrations")); + .andExpect(redirectedUrl("/admin/products/abc/workflow?step=ingredients")); then(service).should().reviewIngredients("abc", List.of("Water", "Glycerin"), false); } From 796d43b18d10d6f2475f2e344a4b469f51cd6bf4 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:40:14 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat(admin):=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=97=90=EC=84=9C=20=ED=95=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=C2=B7=EC=A0=84=EC=84=B1=EB=B6=84=EC=9D=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 어드민이 제품을 등록할 때는 이미 상세 페이지를 보고 있어서 한글 이름을 알고 있다. 검수 단계까지 미루면 같은 제품을 다시 열어야 한다. 함량은 등록 폼에서 받지 않는다 — 성분 행이 저장돼야 각 행에 붙일 수 있기 때문이다. 대신 성분을 함께 넣은 경우 등록 직후 성분 보완 탭으로 보내 그 자리에서 채우게 한다. 결과적으로 기능성만 별도 검수로 남는다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../application/service/ProductService.java | 14 +- .../admin/product/domain/entity/Product.java | 12 + .../FunctionalScreeningProperties.java | 94 ++++++ .../FunctionalScreeningService.java | 292 ++++++++++++++++++ .../controller/ProductController.java | 11 +- src/main/resources/templates/products.html | 8 +- .../presentation/ProductControllerTest.java | 2 +- 7 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java 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 c595b0d..64f8350 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 @@ -43,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; } 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 5ec7793..ba5cec0 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 @@ -76,8 +76,20 @@ 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)) 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..483d318 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -0,0 +1,94 @@ +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; + + /** 이 점수 이상이면 LLM 판정 없이도 확정 후보가 된다(숫자·업체·유형 규칙은 그대로 통과해야 한다). */ + private double autoThreshold = 0.95; + + /** 이 점수 미만은 후보로도 남기지 않는다. 표를 후보로 채워 놓으면 검수가 더 느려진다. */ + private double candidateThreshold = 0.60; + + /** 화면에 남길 후보 수. */ + private int maxCandidates = 5; + + /** + * "기능성 아님"까지 자동 확정할지. 기본은 꺼짐. + * + *

검색 실패와 기능성 아님은 겉보기가 같다(둘 다 후보 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 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 인코딩된 값 그대로). 비어 있으면 조회가 전부 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; + private String model = "claude-opus-5"; + /** 한글 표기를 도저히 모르는 브랜드용. 법인명은 API가 알려주므로 보통 필요 없다. */ + private boolean webSearch = false; + + 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 boolean isWebSearch() { return webSearch; } + public void setWebSearch(boolean webSearch) { this.webSearch = webSearch; } + } +} 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..3d750e9 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -0,0 +1,292 @@ +package com.seoulection.admin.product.functional.application; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.port.CandidateVerdict; +import com.seoulection.admin.product.functional.application.port.FunctionalScreeningRepository; +import com.seoulection.admin.product.functional.application.port.MfdsCatalogPort; +import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; +import com.seoulection.admin.product.functional.application.port.ScreeningTarget; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.ItemName; +import com.seoulection.admin.product.functional.domain.MfdsCandidate; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 기능성 자동 판정의 오케스트레이션. 판정 규칙이 흩어지지 않도록 결정은 전부 여기서 + * 내리고, 조회(안전나라)와 이름 해석(LLM)은 포트에 맡긴다. + * + *

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

    + *
  1. 정규화한 브랜드+제품명으로 바로 검색 — 되는 건 여기서 끝난다(실측 35%).
  2. + *
  3. 등록명 후보로 재검색 — {@code 비타C→비타씨} 같은 표기 차이를 넘는다.
  4. + *
  5. 브랜드 전수 조회 — 등록명이 아무리 달라도 그 브랜드 목록 안에는 들어 있다. + * 회수율의 나머지 절반이 여기서 나온다. 덤으로 "브랜드 등록 0건"이라는 음성 근거와 + * 업체명(ENTP_NAME) 기준값을 얻는다.
  6. + *
+ */ +@Service +public class FunctionalScreeningService { + + private static final Logger log = LoggerFactory.getLogger(FunctionalScreeningService.class); + + /** 자동으로 "기능성 아님"을 확정하면 안 되는 카테고리. 선크림은 법적으로 기능성이어야 한다. */ + private static final List FUNCTIONAL_BY_LAW_CATEGORIES = List.of("sunscreens"); + + /** 이름에 이게 들어 있으면 기능성일 공산이 크다 — 후보 0건이어도 검색 실패로 본다. */ + private static final List FUNCTIONAL_HINTS = + List.of("선크림", "선스틱", "선쿠션", "선세럼", "자차", "톤업", "미백", "주름", "브라이트닝", + "sun", "spf", "uv", "whitening", "brightening", "wrinkle"); + + private final ProductService productService; + private final MfdsCatalogPort mfdsCatalog; + private final ProductNameResolverPort nameResolver; + private final FunctionalScreeningRepository repository; + private final FunctionalScreeningProperties properties; + + /** 브랜드 한글 표기는 브랜드 단위로만 달라진다 — 제품마다 LLM을 부르지 않기 위한 캐시. */ + private final Map> brandAliasCache = new java.util.concurrent.ConcurrentHashMap<>(); + + public FunctionalScreeningService(ProductService productService, + MfdsCatalogPort mfdsCatalog, + ProductNameResolverPort nameResolver, + FunctionalScreeningRepository repository, + FunctionalScreeningProperties properties) { + this.productService = productService; + this.mfdsCatalog = mfdsCatalog; + this.nameResolver = nameResolver; + this.repository = repository; + this.properties = properties; + } + + public Optional find(String productId) { + return repository.findByProductId(productId); + } + + /** 화면에서 쓰는 조회 — 판정이 없으면 그때 한 번 돌린다. */ + public Optional findOrScreen(String productId) { + if (!properties.isEnabled()) { + return Optional.empty(); + } + return Optional.of(repository.findByProductId(productId).orElseGet(() -> screen(productId))); + } + + /** + * 기능성 확인 큐를 한 번에 훑는다. 목록 화면의 [자동 조회 실행] 버튼과 스케줄러가 부른다. + * + * @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()) { + 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.toString()); + 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 = List.of(); + for (String alias : aliases) { + brandItems = mfdsCatalog.searchBrand(alias); + if (!brandItems.isEmpty()) { + brandKo = alias; + break; + } + } + String brandEntpName = dominantEntpName(brandItems); + + List pool = new ArrayList<>(brandItems); + for (String term : searchTerms(target, brandKo)) { + pool.addAll(mfdsCatalog.searchByItemName(term)); + } + + List candidates = rank(pool, target, brandKo, brandEntpName); + long brandCount = brandItems.size(); + + if (candidates.isEmpty()) { + return noCandidate(target, brandCount); + } + + MfdsCandidate top = candidates.get(0); + if (top.confirmable(properties.getAutoThreshold())) { + return confirmed(target, candidates, 0, "등록명이 거의 일치합니다", brandCount); + } + + // 점수로 못 끝낸 경우에만 판정을 부른다 — 유사도가 못 거르는 것들이 여기 남는다. + CandidateVerdict verdict = nameResolver.judge(target, candidates.stream().map(MfdsCandidate::item).toList()); + if (!verdict.matched()) { + return new FunctionalScreening(target.id(), ScreeningOutcome.NEEDS_REVIEW, List.of(), candidates, -1, + verdict.confidence(), "같은 제품으로 볼 후보가 없습니다: " + verdict.reason(), + brandCount, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); + } + + int index = Math.min(Math.max(verdict.index(), 0), candidates.size() - 1); + MfdsCandidate chosen = candidates.get(index); + if (verdict.high() && chosen.numericMatch() && chosen.brandMatch() && chosen.claims().autoConfirmable()) { + return confirmed(target, candidates, index, verdict.reason(), brandCount); + } + String block = chosen.blockReason(); + return new FunctionalScreening(target.id(), ScreeningOutcome.NEEDS_REVIEW, chosen.claims().categories(), + candidates, index, verdict.confidence(), + block.isBlank() ? "판정 신뢰도가 낮아 확인이 필요합니다: " + verdict.reason() : block, + 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 brandAbsent = brandCount == 0; + 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 { + 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(3).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); + return terms.stream().limit(5).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 rank(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); + if (candidate.score() < properties.getCandidateThreshold()) { + continue; + } + 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()) + .limit(properties.getMaxCandidates()) + .toList(); + } + + 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/presentation/controller/ProductController.java b/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java index 70167f9..cfbc1a9 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 @@ -136,8 +136,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"; } diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html index ce063a0..8cc4bcd 100644 --- a/src/main/resources/templates/products.html +++ b/src/main/resources/templates/products.html @@ -179,6 +179,11 @@

제품 등록

+
+ + +

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

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

제품 등록

-

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

+

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

diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 7ea2efc..84d4029 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -77,7 +77,7 @@ void register() throws Exception { .andExpect(redirectedUrl("/admin/products")) .andExpect(flash().attribute("successMessage", "제품을 등록했습니다.")); - then(service).should().register("시카 세럼", "서울렉션", "face masks", List.of()); + then(service).should().register("시카 세럼", null, "서울렉션", "face masks", List.of()); } @Test From 719a194768b28358b2c011c185d20ea225ccde51 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:44:47 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix(admin):=20Anthropic=20Java=20SDK=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=88=84=EB=9D=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=95=B1=EC=9D=B4=20=EA=B8=B0=EB=8F=99=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기능성 스크리닝의 ClaudeProductNameResolver 가 com.anthropic.* 를 쓰는데 build.gradle.kts 에 의존성이 없어 컴파일 단계에서 8건이 깨졌다. ⚠️ LLM 을 끄는 것은 admin.functional-screening.llm.enabled=false 로 하는 것이지 의존성을 빼는 것이 아니다 — @ConditionalOnProperty 는 런타임 스위치라 클래스 자체는 항상 컴파일된다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- build.gradle.kts | 6 + .../FunctionalScreeningProperties.java | 5 - .../FunctionalScreeningService.java | 14 +- .../ClaudeProductNameResolver.java | 167 ++++++++++++++++++ .../FunctionalScreeningConfiguration.java | 10 ++ .../FunctionalScreeningDocument.java | 130 ++++++++++++++ .../FunctionalScreeningMongoRepository.java | 29 +++ .../HeuristicProductNameResolver.java | 75 ++++++++ .../infrastructure/MfdsCatalogClient.java | 129 ++++++++++++++ .../FunctionalScreeningController.java | 53 ++++++ 10 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/seoulection/admin/product/functional/infrastructure/ClaudeProductNameResolver.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningConfiguration.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningMongoRepository.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/infrastructure/HeuristicProductNameResolver.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java create mode 100644 src/main/java/com/seoulection/admin/product/functional/presentation/FunctionalScreeningController.java diff --git a/build.gradle.kts b/build.gradle.kts index 1ac8703..411e46d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,12 @@ repositories { } dependencies { + // Claude API — 기능성 스크리닝의 이름 해석(브랜드 한글 표기·등록명 후보·동일 제품 판정)에 쓴다. + // ⚠️ 이 의존성이 없으면 ClaudeProductNameResolver 가 컴파일되지 않아 앱이 아예 기동하지 않는다. + // LLM 을 끄는 것은 admin.functional-screening.llm.enabled=false 로 하는 것이지, + // 의존성을 빼는 것이 아니다(@ConditionalOnProperty 는 런타임 스위치다). + implementation("com.anthropic:anthropic-java:2.34.0") + implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-validation") 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 index 483d318..d547083 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -81,14 +81,9 @@ public static class Mfds { public static class Llm { private boolean enabled = false; private String model = "claude-opus-5"; - /** 한글 표기를 도저히 모르는 브랜드용. 법인명은 API가 알려주므로 보통 필요 없다. */ - private boolean webSearch = false; - 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 boolean isWebSearch() { return webSearch; } - public void setWebSearch(boolean webSearch) { this.webSearch = webSearch; } } } 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 index 3d750e9..7bf02c1 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -146,7 +146,9 @@ private FunctionalScreening decide(ScreeningTarget target) { long brandCount = brandItems.size(); if (candidates.isEmpty()) { - return noCandidate(target, brandCount); + // 브랜드 전수를 한글 표기로 못 돌렸으면 "0건"이 음성 근거가 되지 못한다 — + // 등록명은 전부 한글이라 영문 브랜드명으로 조회하면 무조건 0건이 나온다. + return noCandidate(target, brandCount, containsHangul(brandKo)); } MfdsCandidate top = candidates.get(0); @@ -185,8 +187,8 @@ private FunctionalScreening confirmed(ScreeningTarget target, List검색 실패와 기능성 * 아님은 겉보기가 같다. 브랜드 등록이 0건일 때만 둘을 가를 수 있다. */ - private FunctionalScreening noCandidate(ScreeningTarget target, long brandCount) { - boolean brandAbsent = brandCount == 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); @@ -203,6 +205,8 @@ private FunctionalScreening noCandidate(ScreeningTarget target, long brandCount) reason = "제품명이 기능성을 암시하는데 등록 건을 찾지 못했습니다 — 직접 확인해 주세요"; } else if (brandAbsent) { reason = "이 브랜드의 기능성 등록이 한 건도 없습니다(기능성 아님일 가능성이 높습니다)"; + } else if (!brandLookupReliable) { + reason = "브랜드 한글 표기를 몰라 전수 조회를 못 했습니다 — 등록 여부를 직접 확인해 주세요"; } else { reason = "브랜드 등록은 " + brandCount + "건 있으나 이 제품과 맞는 건을 찾지 못했습니다"; } @@ -285,6 +289,10 @@ private List rank(List pool, ScreeningTarget target, St .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/infrastructure/ClaudeProductNameResolver.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/ClaudeProductNameResolver.java new file mode 100644 index 0000000..7257804 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/ClaudeProductNameResolver.java @@ -0,0 +1,167 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.StructuredMessageCreateParams; +import com.seoulection.admin.product.functional.application.FunctionalScreeningProperties; +import com.seoulection.admin.product.functional.application.port.CandidateVerdict; +import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; +import com.seoulection.admin.product.functional.application.port.ScreeningTarget; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; + +/** + * 이름 문제를 Claude로 푸는 구현. {@code admin.functional-screening.llm.enabled=true}일 때 뜬다. + * + *

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

실패하면 예외를 던지지 않고 빈 결과를 돌려준다. LLM이 죽었다고 자동화 전체가 멈추면 + * 안 되고, 빈 결과는 곧 "사람이 본다"로 이어지므로 안전한 방향의 실패다. + */ +@Component +@ConditionalOnProperty(name = "admin.functional-screening.llm.enabled", havingValue = "true") +public class ClaudeProductNameResolver implements ProductNameResolverPort { + + private static final Logger log = LoggerFactory.getLogger(ClaudeProductNameResolver.class); + + private final AnthropicClient client; + private final FunctionalScreeningProperties.Llm config; + + public ClaudeProductNameResolver(FunctionalScreeningProperties properties) { + this.config = properties.getLlm(); + this.client = AnthropicOkHttpClient.fromEnv(); // ANTHROPIC_API_KEY + } + + // ── 구조화 출력 스키마 ──────────────────────────────────────────────────── + + public record BrandAliases(List koreanNames) { } + + public record RegistrationNames(List names) { } + + /** {@code index}가 Integer인 이유: 모델이 필드를 빠뜨려도 NPE 대신 -1로 흘려보내기 위함. */ + public record Judgement(Integer index, String confidence, String reason) { } + + // ── ① 브랜드 한글 표기 ──────────────────────────────────────────────────── + + @Override + public List koreanBrandAliases(String brand) { + String prompt = """ + 한국 화장품 브랜드의 한글 표기를 알려 주세요. + + 브랜드: %s + + 의약품안전나라(식약처) 기능성화장품 등록 목록에서 이 브랜드 제품이 어떤 한글 표기로 + 올라가 있을지, 가능성이 높은 순으로 최대 3개 적어 주세요. + 예: Goodal → 구달 / d'Alba → 달바 / Round Lab → 라운드랩 + + - 한글만 적습니다. 법인명(주식회사 ○○)이 아니라 브랜드 표기입니다. + - 모르면 빈 배열을 주세요. 지어내지 마세요. + """.formatted(brand); + + return call(prompt, 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/비타씨 등)은 각각 따로 넣어 최대 5개까지. + - 제품명에 있는 숫자는 절대 바꾸지 마세요. + """.formatted(target.brand(), brandKo, target.displayName()); + + return call(prompt, 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.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("판정 호출에 실패했습니다")); + } + + // ── 호출 ───────────────────────────────────────────────────────────────── + + private Optional call(String prompt, Class schema) { + try { + StructuredMessageCreateParams params = MessageCreateParams.builder() + .model(config.getModel()) + .maxTokens(2048L) + .outputConfig(schema) + .addUserMessage(prompt) + .build(); + return client.messages().create(params).content().stream() + .flatMap(block -> block.text().stream()) + .findFirst() + .map(block -> block.text()); + } catch (RuntimeException e) { + // 여기서 던지면 제품 한 건이 FAILED가 된다. 빈 결과 → 사람 큐가 더 나은 실패다. + log.warn("Claude 호출 실패 ({}): {}", schema.getSimpleName(), e.toString()); + return Optional.empty(); + } + } +} 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..707a82e --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java @@ -0,0 +1,130 @@ +package com.seoulection.admin.product.functional.infrastructure; + +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.functional.domain.ClaimReading; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.MfdsCandidate; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.MfdsSource; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.Instant; +import java.util.List; + +/** + * {@code functional_screenings} 한 건. _id가 곧 product_id다 — 제품당 최신 판정 하나만 둔다. + * + *

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

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

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

URL을 문자열로 조립하는 이유: 서비스 키가 이미 URL 인코딩된 상태로 발급되는데 + * ({@code ...%2FXm1Lv...%3D%3D}) {@code RestClient}의 uri 템플릿에 넣으면 {@code %}가 한 번 + * 더 인코딩돼 인증이 깨진다. 키는 받은 그대로 붙이고, 검색어만 직접 인코딩한다. + * + *

심사(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); + + Map response = client.get().uri(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"; + } +} From be58d6d78be51259ff394a2c8f6d905dcf9e3922 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 11:47:32 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(admin):=20=EC=84=A4=EB=AC=B8=20?= =?UTF-8?q?=EB=AC=B8=ED=95=AD=20=EA=B7=BC=EA=B1=B0=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설문이 "근거 기반"이어야 한다는 요구. 지금은 문항과 선택지만 있어서 "이 문항을 왜 묻는지"가 사람 머릿속에만 있다 — 문항을 고치거나 뺄 때 판단할 자료가 없고, 사용자에게 설명할 근거도 없다. 🔴 1:N 이다. 문항 하나가 근거 하나만 가질 이유가 없다 — 논문 하나로 시작해도 나중에 가이드라인·임상 자료가 붙는다. 1:1 로 만들면 두 번째 근거가 생기는 순간 스키마를 다시 바꾼다. 🔴 rationale 을 필수로 받는다. 링크만 모으면 "왜 묻는지"는 여전히 사람 머릿속에 남는다. 링크를 열지 않고도 판단할 수 있어야 한다. - url 은 nullable(내부 자료). (question_key, url) 유일 인덱스는 url is not null 조건부라 링크 없는 자료는 제목이 같아도 별개로 남는다 — 의도한 동작이다. - source_type 은 신뢰도 표시용이라 알 수 없는 값을 거부하지 않고 ARTICLE 로 흘린다. 근거를 못 남기는 쪽이 더 나쁘다. 마이그레이션은 스키마 주인 두 곳에 넣는다 — api-server V15, skin-service V10. 실제 Postgres 16 에 V1~V15 를 적용해 통과와 삽입을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../FunctionalScreeningProperties.java | 9 +- .../FunctionalScreeningService.java | 21 +++++ .../application/dto/SurveyEvidenceResult.java | 28 ++++++ .../service/SurveyEvidenceService.java | 85 +++++++++++++++++++ .../SurveyQuestionEvidenceJpaEntity.java | 71 ++++++++++++++++ .../SurveyQuestionEvidenceJpaRepository.java | 14 +++ .../controller/SurveyAdminController.java | 54 +++++++++++- src/main/resources/templates/survey.html | 63 ++++++++++++++ 8 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/seoulection/admin/survey/application/dto/SurveyEvidenceResult.java create mode 100644 src/main/java/com/seoulection/admin/survey/application/service/SurveyEvidenceService.java create mode 100644 src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionEvidenceJpaEntity.java create mode 100644 src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionEvidenceJpaRepository.java 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 index d547083..9e16e78 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -9,12 +9,13 @@ public class FunctionalScreeningProperties { private boolean enabled = true; /** - * 자동 판정 결과로 제품 상태를 실제로 옮길지. 기본은 꺼짐 = 그림자 모드. + * 자동 판정 결과로 제품 상태를 실제로 옮길지. 기본은 켜짐 — 한글 이름을 저장하면 그 자리에서 + * 기능성까지 확정되고 어드민은 자동이 못 찾은 것만 손댄다. * - *

켜기 전에 몇 주 동안 자동 판정과 사람 판단을 나란히 두고 비교하라는 뜻이다. - * 기능성은 규제 정보라 틀린 자동 확정이 비어 있는 큐보다 훨씬 비싸다. + *

끄면 그림자 모드가 된다(판정은 기록하되 상태는 그대로). 판정 규칙을 크게 손본 뒤 + * 사람 판단과 대조해 보고 싶을 때 쓰라고 남겨 둔 스위치다. */ - private boolean applyDecisions = false; + private boolean applyDecisions = true; /** 이 점수 이상이면 LLM 판정 없이도 확정 후보가 된다(숫자·업체·유형 규칙은 그대로 통과해야 한다). */ private double autoThreshold = 0.95; 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 index 7bf02c1..9cc7ec7 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -78,6 +78,27 @@ public Optional find(String productId) { return repository.findByProductId(productId); } + public boolean isEnabled() { + return properties.isEnabled(); + } + + /** + * 자동화의 진입점. 어드민이 한글 이름을 저장한 직후에 불린다. + * + *

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

결과가 나오면 기능성까지 확정돼 다음 단계로 넘어가고, 못 찾으면 기능성 폼이 열린 채 + * 후보만 채워진다 — 그때만 사람이 고른다. + */ + public Optional screenAfterNameSaved(String productId) { + if (!properties.isEnabled()) { + return Optional.empty(); + } + return Optional.of(screen(productId)); + } + /** 화면에서 쓰는 조회 — 판정이 없으면 그때 한 번 돌린다. */ public Optional findOrScreen(String productId) { if (!properties.isEnabled()) { 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..0a6955c 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; } @@ -38,6 +41,8 @@ public String page(Model model) { model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); } model.addAttribute("questions", service.getQuestions()); + model.addAttribute("evidenceByQuestion", evidenceService.byQuestion()); + model.addAttribute("sourceTypes", java.util.List.of("PAPER", "GUIDELINE", "CLINICAL", "ARTICLE", "INTERNAL")); return "survey"; } @@ -139,4 +144,49 @@ public String changeQuestionActive(@PathVariable String questionKey, active ? "문항을 다시 노출합니다." : "문항을 숨겼습니다. 기존 응답은 그대로 남습니다."); return "redirect:/admin/survey"; } -} + + // ── 문항 근거 ───────────────────────────────────────────────────────── + // 설문이 "근거 기반"이어야 한다는 요구. 문항 하나에 근거 여러 개가 붙는다(1:N) — + // 논문 하나로 시작해도 나중에 가이드라인·임상 자료가 붙는다. + + @PostMapping("/admin/survey/questions/{questionKey}/evidence") + public String addEvidence(@PathVariable 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()); + } + 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"; + } +} \ No newline at end of file diff --git a/src/main/resources/templates/survey.html b/src/main/resources/templates/survey.html index 93a2f19..08f5940 100644 --- a/src/main/resources/templates/survey.html +++ b/src/main/resources/templates/survey.html @@ -53,6 +53,69 @@

설문 관리

+ +
+
+ 근거 + 0 +
+ +

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

+ +
+ + + + + + + + + + + +
종류근거관리
논문 + + + + + + + + + + 원문 +
+ +
+
+
+ +
+ + + + + + +
+
+
From 7b2dbd9de120dc3f9dc7ca7ac3a834f015726c53 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 12:30:17 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(admin):=20=EB=AC=B8=ED=95=AD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20500=20+?= =?UTF-8?q?=20=EA=B7=BC=EA=B1=B0=20UI=20=EB=A5=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B4=80=EB=A1=80=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 500 SurveyAdminController 에 return "survey" 가 다섯 군데 있는데(정상 렌더 하나 + 검증 실패 네 군데) 근거 목록을 GET 한 곳에만 넣었다. 그래서 중복 문항 키나 빈 값으로 추가하면 템플릿이 null 을 참조해 터졌고, 그 오류를 쓰려다 HttpMessageNotWritableException 으로 500 이 됐다. 정상 흐름은 멀쩡해서 눈으로도 테스트로도 안 걸린다. populateSurveyModel() 로 묶어 손으로 채우는 곳을 없앤다. ## UI 문항마다 표·수정 폼·추가 폼을 펼쳐 두니 정작 자주 쓰는 선택지 편집이 스크롤 아래로 밀렸다. 접기로 한 번 줄였지만 중첩 details 라 여전히 번잡했다. 이 페이지가 이미 쓰는 관례로 맞춘다 — 목록은 선택지와 같은 표, 추가는 상단 툴바 버튼에서 여는 드로어(대상 문항 라디오 + 입력). 문항 카드 안에 추가 폼을 두지 않으니 같은 폼이 문항 수만큼 반복되지 않는다. 실패하면 드로어를 다시 열어 입력이 어디로 갔는지 알 수 있게 한다. 엔드포인트도 드로어에 맞춰 POST /admin/survey/evidence 로 옮겼다(questionKey 는 본문). 접기용으로 넣었던 CSS 는 지웠다 — 죽은 규칙을 남기지 않는다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- build.gradle.kts | 9 +- docs/functional-screening.md | 114 ++++++++--- .../dto/ProductIngredientProperty.java | 24 ++- .../dto/PropertyDefinitionResult.java | 12 +- .../application/service/ProductService.java | 10 + .../admin/product/domain/entity/Product.java | 33 ++- .../FunctionalScreeningProperties.java | 29 ++- .../FunctionalScreeningService.java | 36 +++- ...er.java => GeminiProductNameResolver.java} | 123 +++++++++--- .../infrastructure/MfdsCatalogClient.java | 13 +- .../JdbcProductIngredientAdapter.java | 5 +- .../controller/ProductController.java | 91 ++++++++- .../controller/SurveyAdminController.java | 40 +++- src/main/resources/application.yml | 29 +++ src/main/resources/static/css/admin.css | 7 + .../templates/fragments/ingredient-rows.html | 177 +++++++++++++--- .../resources/templates/product-workflow.html | 96 +++++++++ src/main/resources/templates/survey.html | 163 +++++++++------ .../domain/ProductStatusTransitionTest.java | 40 +++- .../FunctionalScreeningServiceTest.java | 190 ++++++++++++++++++ .../domain/FunctionalClaimsTest.java | 56 ++++++ .../functional/domain/ItemNameTest.java | 41 ++++ .../MfdsCatalogClientLiveTest.java | 74 +++++++ .../FunctionalWorkflowViewTest.java | 94 +++++++++ .../presentation/IngredientRowsViewTest.java | 119 +++++++++++ .../presentation/ProductControllerTest.java | 5 + .../presentation/ProductDetailViewTest.java | 59 ++++++ 27 files changed, 1491 insertions(+), 198 deletions(-) rename src/main/java/com/seoulection/admin/product/functional/infrastructure/{ClaudeProductNameResolver.java => GeminiProductNameResolver.java} (59%) create mode 100644 src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java create mode 100644 src/test/java/com/seoulection/admin/product/functional/domain/FunctionalClaimsTest.java create mode 100644 src/test/java/com/seoulection/admin/product/functional/domain/ItemNameTest.java create mode 100644 src/test/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClientLiveTest.java create mode 100644 src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java create mode 100644 src/test/java/com/seoulection/admin/product/presentation/IngredientRowsViewTest.java create mode 100644 src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 411e46d..a0aafee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,11 +18,9 @@ repositories { } dependencies { - // Claude API — 기능성 스크리닝의 이름 해석(브랜드 한글 표기·등록명 후보·동일 제품 판정)에 쓴다. - // ⚠️ 이 의존성이 없으면 ClaudeProductNameResolver 가 컴파일되지 않아 앱이 아예 기동하지 않는다. - // LLM 을 끄는 것은 admin.functional-screening.llm.enabled=false 로 하는 것이지, - // 의존성을 빼는 것이 아니다(@ConditionalOnProperty 는 런타임 스위치다). - implementation("com.anthropic:anthropic-java:2.34.0") + // 기능성 스크리닝의 이름 해석(브랜드 한글 표기·등록명 후보·동일 제품 판정)은 Gemini를 쓴다. + // SDK 의존성 없이 RestClient로 REST를 직접 부른다 — 이 프로젝트의 다른 외부 연동 + // (MFDS, V2 product-service, 알림)과 같은 방식이고, 새 의존성이 필요 없다. implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") @@ -33,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 index ef43acf..bdb1d6a 100644 --- a/docs/functional-screening.md +++ b/docs/functional-screening.md @@ -1,8 +1,16 @@ # 기능성 검수 자동화 (Functional Screening) -지금 어드민의 "2 기능성 확인" 큐는 100% 수동이다. 사람이 의약품안전나라를 직접 검색해 -기능성 여부를 판단하고 유형을 고른다. 이 문서는 그 판단을 자동으로 시도하고, **자동으로 -확신할 수 없을 때만 사람에게 넘기는** 구조를 설명한다. +지금 어드민의 "기능성 확인" 탭은 100% 수동이다. 사람이 의약품안전나라를 직접 검색해 기능성 +여부를 판단하고 유형을 고른다. 이 문서는 그 판단을 자동으로 시도하고, **자동으로 확신할 수 +없을 때만 사람에게 넘기는** 구조를 설명한다. + +**동작 흐름은 한 줄이다.** 기능성 확인 탭에서 *한글 이름을 저장하면* 그 자리에서 안전나라를 +조회해 기능성까지 기록하고 다음 건으로 넘어간다. 어드민이 손대는 건 자동이 확정하지 못한 +제품뿐이다. + +한글 이름이 트리거인 이유: 안전나라 등록명(`ITEM_NAME`)은 전부 한글이고 브랜드 한글 표기로 +시작한다("구달청귤비타씨잡티세럼"). 영문 제품명만으로는 조회가 시작조차 안 되므로, 한글 +이름이 채워지는 그 순간이 자동 조회가 가장 잘 듣는 시점이다. 목표는 "수동을 없애는 것"이 아니라 **수동 큐를 줄이는 것**이다. 기능성은 규제 정보라서 틀린 자동 확정은 비어 있는 큐보다 훨씬 비싸다. @@ -14,15 +22,24 @@ 원래 구상은 "한글 제품명 → LLM이 실제 법인명(주식회사 ○○)을 찾음 → **그 회사명으로** 안전나라 API 호출"이었다. 실제로 두 API를 찔러 확인한 결과 **이 경로는 성립하지 않는다.** -``` -GET .../getRptPrdlstInq?...&entp_name=클리오 → totalCount 195220 (= 전체 건수) -GET .../getRptPrdlstInq?...&entpName=클리오 → totalCount 195220 -GET .../getRptPrdlstInq?...&ENTP_NAME=클리오 → totalCount 195220 -GET .../getRptPrdlstInq?...&bizrno=1198134685 → totalCount 195220 -``` +전체 건수는 보고 195,220건 / 심사 20,629건이다. 필터가 실제로 먹는지는 건수가 줄어드는지로 +알 수 있다. -`item_name` **말고는 어떤 파라미터도 필터로 동작하지 않는다.** 업체명·사업자번호·기능성 -구분 모두 무시되고 전체 목록이 그대로 나온다. 즉 **법인명으로는 조회 자체가 불가능하다.** +| 파라미터 | 보고(1471000) | 심사(1471057) | 동작 | +|---|---|---|---| +| `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` | 195,220 | 20,629 | ❌ 무시 | +| `bizrno` (사업자번호) | 195,220 | 20,629 | ❌ 무시 | +| `ee_code` (효능코드) | 195,220 | — | ❌ 무시 | +| `report_flag_code` | 195,220 | — | ❌ 무시 | +| `dept_receipt_no` | 195,220 | — | ❌ 무시 | + +동작하는 셋 중 `item_seq`·`cosmetic_report_seq`는 **조회 결과로 얻는 고유번호**라 첫 조회의 +키가 될 수 없다. 결국 **우리가 미리 알 수 있는 검색 키는 제품 이름뿐이고, 법인명으로는 +조회 자체가 불가능하다.** 그래서 법인명의 역할을 바꿨다. @@ -93,6 +110,9 @@ LLM은 **기능성을 판단하지 않는다.** 기능성 유형은 오직 안 LLM은 "무엇을 검색할지"와 "이 후보가 같은 제품인지"만 답한다. 규제 데이터를 모델이 지어내지 못하게 하는 경계다. +모델은 **Gemini**(`gemini-3.8-flash`)를 쓴다. Interactions API(`/v1beta/interactions`)를 +`RestClient`로 직접 부른다 — 이 프로젝트의 다른 외부 연동과 같은 방식이라 SDK 의존성이 없다. + | 자리 | 입력 | 출력 | 왜 LLM인가 | |---|---|---|---| | ① 브랜드 한글 표기 | `Goodal`, `d'Alba`, `Round Lab` | `구달`, `달바`, `라운드랩` | 음차 표기는 규칙으로 못 만든다. 브랜드당 **1회**만 부르고 캐시한다 | @@ -102,8 +122,9 @@ LLM은 "무엇을 검색할지"와 "이 후보가 같은 제품인지"만 답한 ①은 브랜드 단위 캐시라 호출이 거의 없고, ②③은 제품당 최대 2회다. 후보가 0건이거나 정규화만으로 완전 일치하면 ③은 아예 부르지 않는다. -> 웹 검색이 필요한가: 기본은 아니오. 법인명은 API 응답(`ENTP_NAME`)이 알려주므로 검색할 -> 이유가 없다. 한글 표기를 도저히 모르는 브랜드용으로 `llm.web-search=true` 토글만 남겨 둔다. +> 웹 검색은 쓰지 않는다. 법인명은 API 응답(`ENTP_NAME`)이 알려주므로 검색할 이유가 없고, +> 한글 표기를 끝내 모르는 브랜드는 `NOT_MATCHED`로 사람 큐에 남는다 — 모델이 추측한 표기로 +> 조회해서 0건을 받고 "기능성 아님"으로 접는 쪽이 훨씬 위험하다. --- @@ -133,18 +154,19 @@ LLM은 "무엇을 검색할지"와 "이 후보가 같은 제품인지"만 답한 ## 5. 판정 등급과 폴백 ``` - ┌─────────────────────────────────────────┐ - INGREDIENTS_ADDED ─┤ 1. 직접 검색 (정규화된 브랜드+제품명) │ - (기능성 확인 큐) │ 2. LLM 등록명 후보로 재검색 │ - │ 3. 브랜드 전수 조회 후 그 안에서 매칭 │ - │ 4. 취하 건 제외 · 점수 정렬 · 상위 5건 │ - └───────────────┬─────────────────────────┘ + 한글 이름 저장 ────┐ ┌─────────────────────────────────────────┐ + (기능성 확인 탭) ├─┤ 1. 직접 검색 (정규화된 브랜드+제품명) │ + │ │ 2. LLM 등록명 후보로 재검색 │ + INGREDIENTS_ADDED ─┘ │ 3. 브랜드 전수 조회 후 그 안에서 매칭 │ + │ 4. 취하 건 제외 · 점수 정렬 · 상위 5건 │ + └───────────────┬─────────────────────────┘ │ ┌────────────────────────────┼────────────────────────────┐ ▼ ▼ ▼ AUTO_CONFIRMED NEEDS_REVIEW NOT_MATCHED 자동 확정 후보는 있는데 애매 후보 0건 - → READY_FOR_INCIAPI → 어드민 큐 (후보 제시) → 어드민 큐 + → READY_FOR_INCIAPI → 같은 화면에 후보 제시 → 같은 화면, 사유 표시 + → 큐로 복귀, 다음 건 → 사람이 고름 → 사람이 직접 입력 (+ 브랜드 0건이면 AUTO_NONE 후보) ``` @@ -159,7 +181,8 @@ LLM은 "무엇을 검색할지"와 "이 후보가 같은 제품인지"만 답한 **AUTO_NONE("기능성 아님" 자동 확정)** — 기본 **꺼져 있다**(`auto-conclude-none: false`). 켤 경우에도 아래를 모두 만족할 때만 확정한다. -- 브랜드 전수 조회가 정상 종료했고 그 브랜드의 등록 건이 **0건** +- 브랜드 전수 조회가 **한글 표기로** 정상 종료했고 그 브랜드의 등록 건이 **0건** + (영문 브랜드명으로 조회하면 등록명이 전부 한글이라 무조건 0건이 나온다 — 이 0건은 근거가 아니다) - 카테고리가 `sunscreens`가 아님 (선크림은 법적으로 기능성이어야 한다 — 미검색은 실패로 본다) - 제품명에 `선/자차/톤업/미백/주름/브라이트닝/UV/SPF` 같은 기능성 암시 토큰이 없음 @@ -194,11 +217,18 @@ functional_screenings ## 7. 화면 -기능성 단계(4단계) 폼 위에 **자동 조회 결과 카드**가 붙는다. +기능성 확인 탭은 위에서 아래로 이렇게 흐른다. -- 후보 표: 등록명 · 업체명 · 보고일 · 도출된 유형 · 점수 — 각 행에 [이 후보로 채우기] -- 자동 판정 사유 한 줄 (`숫자 불일치로 자동 확정 보류: 311 vs 131`) -- [다시 조회] 버튼 +1. **한글 이름 + [저장하고 자동 조회]** — 이게 자동화의 입구다. 누르면 저장·조회·기록이 + 한 번에 끝나고, 확정되면 큐로 돌아간다. +2. **자동 조회 결과 카드** — 확정하지 못했을 때만 의미가 있다. + - 판정 배지와 사유 한 줄 (`제품명 숫자가 다릅니다(131) — 다른 제품일 수 있습니다`) + - 후보 표: 등록명 · 업체명 · 등록일 · 효능효과 · 도출 유형 · 점수 — 각 행에 [이 후보로 채우기] + - 브랜드 전수 건수 (`이 브랜드의 안전나라 기능성 등록: 37건`) +3. **직접 입력 폼** — 지금 쓰는 그 폼 그대로. 자동이 못 한 것만 사람이 채운다. + +"왜 자동으로 안 됐는가"를 함께 보여 주는 게 중요하다. 사유가 없으면 어드민이 방금 기계가 +한 검색을 처음부터 다시 한다. **라디오는 절대 미리 선택하지 않는다.** 지금 폼이 기본값을 두지 않는 이유(확인 없이 저장 버튼만 눌러도 "기능성 아님"이 사실로 기록되는 걸 막는 것)가 자동화에서도 그대로 유효하다. @@ -220,19 +250,37 @@ admin: max-brand-pages: 4 # 브랜드 전수 상한 (2000건) auto-threshold: 0.95 candidate-threshold: 0.60 + apply-decisions: true # 자동 확정을 상태 전진으로 옮긴다(끄면 그림자 모드) auto-conclude-none: false # "기능성 아님" 자동 확정 (기본 꺼짐) llm: - enabled: false # 켜면 ①②③ 자리에 Claude가 들어간다 - model: claude-opus-5 - web-search: false + enabled: false # 켜면 ①②③ 자리에 Gemini가 들어간다 + model: gemini-3.8-flash + api-key: ${GEMINI_API_KEY:} ``` +`MFDS_SERVICE_KEY`(data.go.kr)와, LLM을 켤 경우 `GEMINI_API_KEY`(Google AI Studio)가 필요하다. +둘 다 `seoulection-server-V2/.env`에 넣고 compose가 어드민 컨테이너에 환경변수로 꽂아 준다. + `llm.enabled=false`면 규칙 기반 대체 구현이 그 자리를 메운다(정규화 변형만 생성, 후보 판정은 점수로만). 즉 **LLM 없이도 동작하고**, 켜면 회수율이 올라간다. -## 9. 단계적 도입 +## 9. 지금 상태와 남은 것 + +**들어간 것** + +- 안전나라 심사·보고 조회, 브랜드 전수 조회, 취하 건 제외 +- 유사도 + 숫자 토큰 + 업체명 + 기능성 도출 규칙에 의한 자동 확정 판정 +- 한글 이름 저장이 트리거인 자동 조회, 후보 표와 [이 후보로 채우기] +- LLM 자리(브랜드 표기·등록명 후보·후보 판정)와 규칙 기반 대체 구현 — 설정 한 줄로 갈린다 +- 판정 기록(`functional_screenings`)과 `decidedBy`·`engineVersion` + +**아직 안 한 것 / 켤 때 판단이 필요한 것** -1. **그림자 모드** — `auto-conclude-none=false`, 자동 확정도 저장하지 않고 판정만 기록. - 어드민 화면엔 후보만 보여 준다. 사람 판단과 자동 판정을 몇 주 비교한다. -2. **자동 확정 켜기** — 1의 일치율이 충분하면 AUTO_CONFIRMED가 status를 전진시킨다. -3. **AUTO_NONE 켜기** — 브랜드 0건 케이스의 정확도가 확인된 뒤에만. +1. **LLM 켜기** (`llm.enabled=true` + `GEMINI_API_KEY`) — 회수율이 오르는 대신 제품당 최대 + 2회 호출이 붙는다(브랜드 표기는 브랜드당 1회 캐시). 먼저 규칙만으로 몇 %가 자동 확정되는지 + 본 뒤에 켜는 편이 낫다. +2. **AUTO_NONE 켜기** (`auto-conclude-none=true`) — 브랜드 0건 케이스의 정확도를 실제 + 데이터로 확인한 뒤에만. 지금은 그 케이스도 사람 큐로 간다. +3. **밀린 제품 일괄 조회** — `POST /admin/products/functional-screening`이 큐를 훑는다 + (기본 50건). 목록 화면에 버튼은 아직 안 붙였다. +4. **재판정** — 규칙을 고치면 `engineVersion`이 다른 판정을 골라 다시 돌리는 일이 필요해진다. 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 index 5997799..09e9b6e 100644 --- a/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductIngredientProperty.java @@ -12,13 +12,29 @@ *

수치는 {@code valueMin}/{@code valueMax}, 범주형은 {@code valueText} 를 쓴다. * 어느 쪽을 쓸지는 {@code property_definition.value_type} 이 정한다. */ -public record ProductIngredientProperty(String propertyKey, String displayNameKo, String valueText, - BigDecimal valueMin, BigDecimal valueMax, String valueUnit, - String notes) { +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, valueText, valueMin, valueMax, valueUnit, 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 %", 범주형이면 그 문자열. */ 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 index 78e9ce4..b0d9d1c 100644 --- a/src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java +++ b/src/main/java/com/seoulection/admin/product/application/dto/PropertyDefinitionResult.java @@ -3,12 +3,20 @@ /** * 특성 키의 정의. 화면이 "무엇을 입력할 수 있는가"를 이 목록으로 그린다. * - *

{@code valueType} 이 NUMERIC/NUMERIC_RANGE 면 숫자 칸을, 그 외에는 텍스트 칸을 보여준다. + *

{@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() { - return valueType != null && valueType.toUpperCase().startsWith("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/service/ProductService.java b/src/main/java/com/seoulection/admin/product/application/service/ProductService.java index 64f8350..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 @@ -105,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 ba5cec0..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 @@ -95,7 +95,8 @@ public static Product pending(String name, String nameKo, String brand, String c .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(); } @@ -143,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() @@ -156,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단계 검수 — 식약처 기능성 결과만 갱신한다. 성분은 손대지 않는다. * 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 index 9e16e78..004d178 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -53,7 +53,12 @@ public class FunctionalScreeningProperties { public Llm getLlm() { return llm; } public static class Mfds { - /** data.go.kr 발급 키(URL 인코딩된 값 그대로). 비어 있으면 조회가 전부 FAILED로 남는다. */ + /** + * 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"; @@ -81,10 +86,30 @@ public static class Mfds { public static class Llm { private boolean enabled = false; - private String model = "claude-opus-5"; + + /** 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 index 9cc7ec7..a0facd9 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -99,12 +99,39 @@ public Optional screenAfterNameSaved(String productId) { return Optional.of(screen(productId)); } - /** 화면에서 쓰는 조회 — 판정이 없으면 그때 한 번 돌린다. */ + /** + * 화면에서 쓰는 조회 — 판정이 없으면 그때 한 번 돌린다. + * + *

한글 이름이 없으면 돌리지 않는다. 등록명이 전부 한글이라 영문명으로는 어차피 0건이고, + * 그 0건이 "검색 결과 없음"으로 기록되면 사람이 잘못된 근거를 보게 된다. + */ public Optional findOrScreen(String productId) { if (!properties.isEnabled()) { return Optional.empty(); } - return Optional.of(repository.findByProductId(productId).orElseGet(() -> screen(productId))); + 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()))); } /** @@ -116,6 +143,9 @@ 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); } @@ -133,7 +163,7 @@ public FunctionalScreening screen(ProductResult product) { screening = decide(target); } catch (RuntimeException e) { // 조회·판정 실패는 "기능성 아님"이 아니다. 사유만 남기고 큐에 그대로 둔다. - log.warn("기능성 자동 판정 실패 productId={} : {}", target.id(), e.toString()); + log.warn("기능성 자동 판정 실패 productId={}", target.id(), e); screening = FunctionalScreening.failed(target.id(), "조회 중 오류: " + e.getMessage()); } repository.save(screening); diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/ClaudeProductNameResolver.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java similarity index 59% rename from src/main/java/com/seoulection/admin/product/functional/infrastructure/ClaudeProductNameResolver.java rename to src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java index 7257804..ce6b931 100644 --- a/src/main/java/com/seoulection/admin/product/functional/infrastructure/ClaudeProductNameResolver.java +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java @@ -1,9 +1,5 @@ package com.seoulection.admin.product.functional.infrastructure; -import com.anthropic.client.AnthropicClient; -import com.anthropic.client.okhttp.AnthropicOkHttpClient; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.StructuredMessageCreateParams; import com.seoulection.admin.product.functional.application.FunctionalScreeningProperties; import com.seoulection.admin.product.functional.application.port.CandidateVerdict; import com.seoulection.admin.product.functional.application.port.ProductNameResolverPort; @@ -12,44 +8,75 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.IntStream; /** - * 이름 문제를 Claude로 푸는 구현. {@code admin.functional-screening.llm.enabled=true}일 때 뜬다. + * 이름 문제를 Gemini로 푸는 구현. {@code admin.functional-screening.llm.enabled=true}일 때 뜬다. * - *

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

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

실패하면 예외를 던지지 않고 빈 결과를 돌려준다. LLM이 죽었다고 자동화 전체가 멈추면 - * 안 되고, 빈 결과는 곧 "사람이 본다"로 이어지므로 안전한 방향의 실패다. + *

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

실패하면 예외를 던지지 않고 빈 결과를 돌려준다. LLM이 죽었다고 자동화 전체가 멈추면 안 되고, + * 빈 결과는 곧 "사람이 본다"로 이어지므로 안전한 방향의 실패다. */ @Component @ConditionalOnProperty(name = "admin.functional-screening.llm.enabled", havingValue = "true") -public class ClaudeProductNameResolver implements ProductNameResolverPort { +public class GeminiProductNameResolver implements ProductNameResolverPort { - private static final Logger log = LoggerFactory.getLogger(ClaudeProductNameResolver.class); + private static final Logger log = LoggerFactory.getLogger(GeminiProductNameResolver.class); - private final AnthropicClient client; + private final RestClient client; + private final ObjectMapper objectMapper; private final FunctionalScreeningProperties.Llm config; - public ClaudeProductNameResolver(FunctionalScreeningProperties properties) { + public GeminiProductNameResolver(FunctionalScreeningProperties properties, ObjectMapper objectMapper) { this.config = properties.getLlm(); - this.client = AnthropicOkHttpClient.fromEnv(); // ANTHROPIC_API_KEY + 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) { } - /** {@code index}가 Integer인 이유: 모델이 필드를 빠뜨려도 NPE 대신 -1로 흘려보내기 위함. */ - public record Judgement(Integer index, String confidence, String reason) { } + 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")); // ── ① 브랜드 한글 표기 ──────────────────────────────────────────────────── @@ -68,7 +95,7 @@ public List koreanBrandAliases(String brand) { - 모르면 빈 배열을 주세요. 지어내지 마세요. """.formatted(brand); - return call(prompt, BrandAliases.class) + return call(prompt, stringArraySchema("koreanNames"), BrandAliases.class) .map(BrandAliases::koreanNames) .orElse(List.of()); } @@ -96,7 +123,7 @@ public List registrationNameCandidates(ScreeningTarget target, String br - 제품명에 있는 숫자는 절대 바꾸지 마세요. """.formatted(target.brand(), brandKo, target.displayName()); - return call(prompt, RegistrationNames.class) + return call(prompt, stringArraySchema("names"), RegistrationNames.class) .map(RegistrationNames::names) .orElse(List.of()); } @@ -136,7 +163,7 @@ public CandidateVerdict judge(ScreeningTarget target, List candidates) reason: 한국어 한 문장. """.formatted(target.brand(), target.displayName(), target.category(), rows); - return call(prompt, Judgement.class) + return call(prompt, JUDGEMENT_SCHEMA, Judgement.class) .map(judgement -> new CandidateVerdict( judgement.index() == null ? -1 : judgement.index(), judgement.confidence() == null ? "LOW" : judgement.confidence(), @@ -146,22 +173,54 @@ public CandidateVerdict judge(ScreeningTarget target, List candidates) // ── 호출 ───────────────────────────────────────────────────────────────── - private Optional call(String prompt, Class schema) { + /** + * 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 { - StructuredMessageCreateParams params = MessageCreateParams.builder() - .model(config.getModel()) - .maxTokens(2048L) - .outputConfig(schema) - .addUserMessage(prompt) - .build(); - return client.messages().create(params).content().stream() - .flatMap(block -> block.text().stream()) - .findFirst() - .map(block -> block.text()); + 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("Claude 호출 실패 ({}): {}", schema.getSimpleName(), e.toString()); + 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/MfdsCatalogClient.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java index 92163ea..7115918 100644 --- a/src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/MfdsCatalogClient.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -19,9 +20,10 @@ /** * 의약품안전나라(data.go.kr) 기능성화장품 조회 클라이언트. * - *

URL을 문자열로 조립하는 이유: 서비스 키가 이미 URL 인코딩된 상태로 발급되는데 - * ({@code ...%2FXm1Lv...%3D%3D}) {@code RestClient}의 uri 템플릿에 넣으면 {@code %}가 한 번 - * 더 인코딩돼 인증이 깨진다. 키는 받은 그대로 붙이고, 검색어만 직접 인코딩한다. + *

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

심사(1471057)와 보고(1471000)는 응답 스키마가 다르다. 심사 쪽엔 효능효과(EE_NAME)가 * 없어서 기능성 유형을 도출할 수 없다 — 그래서 심사에서만 발견된 제품은 "기능성이긴 하다"까지만 @@ -80,7 +82,10 @@ private List fetch(String baseUrl, MfdsSource source, String itemName, + "&type=json&pageNo=" + page + "&numOfRows=" + rows + "&item_name=" + URLEncoder.encode(itemName, StandardCharsets.UTF_8); - Map response = client.get().uri(url).retrieve().body(Map.class); + // ⚠️ 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)) { 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 index 7c77098..5a30e59 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java @@ -57,7 +57,7 @@ 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, + 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 @@ -66,7 +66,8 @@ public List findByProductId(String productId) { """, 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_text"), rs.getBigDecimal("value_min"), rs.getBigDecimal("value_max"), + rs.getString("value_type"), rs.getString("value_text"), + rs.getBigDecimal("value_min"), rs.getBigDecimal("value_max"), rs.getString("value_unit"), rs.getString("notes"))); }, productId); 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 cfbc1a9..abb0b93 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; @@ -31,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; } @@ -267,10 +273,83 @@ public String workflowIngredients(@PathVariable String id, @ModelAttribute Produ redirectAttributes.addFlashAttribute("successMessage", "성분을 찾지 못함으로 저장했습니다."); return "redirect:/admin/products?stage=ingredient-review"; } - redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다. 이어서 함량을 입력하세요."); + 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()) { + // 규제 정보가 조용히 저장되고 화면만 넘어가면 나중에 되짚을 실마리가 없다 — + // 무엇이 어떤 근거로 기록됐는지 문구로 남긴다. + redirectAttributes.addFlashAttribute("successMessage", + "한글 이름을 저장하고 기능성을 자동 확정했습니다 — " + describe(screening)); + return "redirect:/admin/products?stage=functional-review"; + } + redirectAttributes.addFlashAttribute("errorMessage", + "자동 조회로 확정하지 못했습니다(" + screening.outcome().displayName() + "): " + + screening.reason() + " 아래에서 직접 확인해 주세요."); + return "redirect:/admin/products/" + id + "/workflow?step=functional"; + } + + /** 자동 확정 결과 문구. 유형이 비어 있으면 "기능성 아님"으로 확정된 것이다. */ + 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단계 저장 — 식약처 기능성만. 저장 후 기능성 확인 큐로 돌아간다. */ @PostMapping("/admin/products/{id}/workflow/functions") public String workflowFunctions(@PathVariable String id, @ModelAttribute ProductRegisterRequest request, @@ -295,6 +374,9 @@ 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"; } @@ -347,6 +429,11 @@ public String workflowPage(@PathVariable String id, model.addAttribute("productIngredients", service.getProductIngredients(id)); model.addAttribute("propertyDefinitions", service.propertyDefinitions()); } + if ("functional".equals(resolved)) { + // 한글 이름이 이미 있으면 화면을 여는 것만으로 자동 조회가 한 번 돈다. 없으면 + // 조회할 근거가 없으니 아무것도 하지 않고 입력 칸만 보여 준다. + model.addAttribute("screening", screeningService.findOrScreen(id).orElse(null)); + } return "product-workflow"; } } 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 0a6955c..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 @@ -40,9 +40,7 @@ public String page(Model model) { if (!model.containsAttribute("questionRequest")) { model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); } - model.addAttribute("questions", service.getQuestions()); - model.addAttribute("evidenceByQuestion", evidenceService.byQuestion()); - model.addAttribute("sourceTypes", java.util.List.of("PAPER", "GUIDELINE", "CLINICAL", "ARTICLE", "INTERNAL")); + populateSurveyModel(model); return "survey"; } @@ -57,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"; } @@ -67,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"; } @@ -82,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"; } @@ -94,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"; } @@ -149,8 +147,9 @@ public String changeQuestionActive(@PathVariable String questionKey, // 설문이 "근거 기반"이어야 한다는 요구. 문항 하나에 근거 여러 개가 붙는다(1:N) — // 논문 하나로 시작해도 나중에 가이드라인·임상 자료가 붙는다. - @PostMapping("/admin/survey/questions/{questionKey}/evidence") - public String addEvidence(@PathVariable String questionKey, + /** 드로어에서 문항을 골라 추가한다 — 문항 카드마다 폼을 두면 같은 폼이 문항 수만큼 반복된다. */ + @PostMapping("/admin/survey/evidence") + public String addEvidence(@RequestParam String questionKey, @RequestParam String title, @RequestParam String rationale, @RequestParam(required = false) String url, @@ -162,6 +161,8 @@ public String addEvidence(@PathVariable String questionKey, redirectAttributes.addFlashAttribute("successMessage", "근거를 추가했습니다."); } catch (IllegalArgumentException e) { redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + // 실패하면 드로어를 다시 열어 준다 — 닫히면 사용자가 입력한 것이 어디로 갔는지 알 수 없다. + redirectAttributes.addFlashAttribute("evidenceFormOpen", true); } return "redirect:/admin/survey"; } @@ -189,4 +190,23 @@ public String deleteEvidence(@PathVariable Long id, RedirectAttributes redirectA redirectAttributes.addFlashAttribute("successMessage", "근거를 삭제했습니다."); return "redirect:/admin/survey"; } -} \ No newline at end of file + + /** + * 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..576f990 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,5 +17,34 @@ spring: thymeleaf: cache: false +# 기능성 자동 판정. 한글 이름을 저장하면 의약품안전나라(심사·보고)를 조회해 기능성까지 기록하고, +# 확정하지 못한 건만 어드민 큐에 남는다. 자세한 설계는 docs/functional-screening.md 참조. +admin: + functional-screening: + enabled: ${FUNCTIONAL_SCREENING_ENABLED:true} + # 자동 확정을 실제 상태 전진(READY_FOR_INCIAPI)으로 옮길지. 끄면 그림자 모드(판정만 기록). + apply-decisions: true + auto-threshold: 0.95 # 이 점수 이상이면 LLM 판정 없이도 확정 후보 + candidate-threshold: 0.60 # 이 미만은 후보로도 남기지 않는다 + max-candidates: 5 + # "기능성 아님"까지 자동 확정할지. 검색 실패와 구분이 어려워 기본은 꺼 둔다 — + # 브랜드 등록이 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 a9f608e..0eac88f 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -1082,3 +1082,10 @@ td a:hover { text-decoration: underline; } .topbar { padding: 0 20px; } .form-grid, .form-grid.cols-4 { grid-template-columns: 1fr; } } + + + +/* 근거 표 — 선택지 표와 같은 결. 이유(rationale)는 폭을 채워 두 줄로 읽히게 한다. */ +.evidence-table td { vertical-align: top; } +.evidence-form textarea { width: 100%; min-width: 260px; resize: vertical; } +.empty-inline { margin: 10px 0 0; color: var(--text-secondary); font-size: 12.5px; } diff --git a/src/main/resources/templates/fragments/ingredient-rows.html b/src/main/resources/templates/fragments/ingredient-rows.html index 0bacf41..565d7ca 100644 --- a/src/main/resources/templates/fragments/ingredient-rows.html +++ b/src/main/resources/templates/fragments/ingredient-rows.html @@ -13,23 +13,31 @@

성분별 보완

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

성분 사전 -
+
+

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

+
+ +
1 성분명 - 사전 연결 - 사전에 없음 - + 사전 연결 +
@@ -53,35 +61,63 @@

성분별 보완 이 제품에서의 특성 -
-
- - - -
- - - -
-
- - - -
+ + + + + + + +
+
+
+ 특성 + + +
+ +
+ + + +
+
+ + + +
-

빈 칸은 저장하지 않습니다 — 값을 지우면 그 특성이 삭제됩니다.

+ +
+ + +
+

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

정의된 특성이 없습니다. 특성 정의에서 먼저 추가하세요. @@ -95,5 +131,82 @@

성분별 보완이 성분 저장

+ +
+
+ 사전에 없어 보완하지 않는 성분 + +
+
+ +
+

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

+
+ + diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index cdcc900..4cd2874 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -72,6 +72,18 @@

+ + + + +
@@ -84,8 +96,79 @@

등록된 성분이 없습니다.

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

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

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

+

+ +

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

+
+
+
직접 입력자동 조회가 확정하지 못했을 때
제품 한글 이름 @@ -153,6 +236,19 @@

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

설문 관리

+ @@ -54,67 +55,46 @@

설문 관리

-
-
- 근거 - 0 -
- -

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

- -
- - - - - - - - - - - -
종류근거관리
논문 - - - - - - - - - - 원문 -
- -
-
-
- -
- - - - - - -
+
+ + + + + + + + + + + +
종류근거 · 묻는 이유관리
논문 +
+ + + + + + +
+
+ 원문 +
+ +
+
+

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

@@ -264,6 +244,71 @@

선택지 추가

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

여기 있는 이유: 이 연동에서 깨지는 건 대개 우리 로직이 아니라 키 인코딩과 응답 스키마다. + * 단위 테스트는 대역을 쓰므로 그 둘을 못 잡는다. 키를 새로 발급했거나 조회가 이상할 때 + * {@code MFDS_SERVICE_KEY=... ./gradlew test --tests '*MfdsCatalogClientLiveTest*'} 로 확인한다. + */ +@EnabledIfEnvironmentVariable(named = "MFDS_SERVICE_KEY", matches = ".+") +class MfdsCatalogClientLiveTest { + + private MfdsCatalogClient client() { + FunctionalScreeningProperties properties = new FunctionalScreeningProperties(); + properties.getMfds().setServiceKey(System.getenv("MFDS_SERVICE_KEY")); + return new MfdsCatalogClient(properties); + } + + @Test + @DisplayName("브랜드 전수 조회로 제품을 찾고 기능성 유형까지 도출한다") + void findsProductThroughBrandScan() { + List items = client().searchBrand("구달"); + assertThat(items).isNotEmpty(); + + String query = "구달청귤비타씨잡티세럼"; + MfdsItem best = items.stream() + .max(Comparator.comparingDouble(item -> ItemName.similarity(query, item.itemName()))) + .orElseThrow(); + + System.out.println("[브랜드 전수] 구달 " + items.size() + "건, 최고 후보: " + best.itemName() + + " / " + best.entpName() + " / " + FunctionalClaims.read(best).categories()); + + assertThat(ItemName.similarity(query, best.itemName())).isGreaterThan(0.9); + assertThat(best.entpName()).contains("클리오"); + assertThat(FunctionalClaims.read(best).autoConfirmable()).isTrue(); + } + + @Test + @DisplayName("효능효과가 비어 있는 선크림도 SPF/PA로 자외선 차단이 도출된다") + void derivesUvProtectionFromSpf() { + List items = client().searchByItemName("달바워터풀톤업선크림"); + assertThat(items).isNotEmpty(); + + MfdsItem sunscreen = items.get(0); + System.out.println("[선크림] " + sunscreen.itemName() + " / EE=" + sunscreen.eeName() + + " / SPF=" + sunscreen.spf() + " PA=" + sunscreen.pa() + + " → " + FunctionalClaims.read(sunscreen).categories()); + + assertThat(FunctionalClaims.read(sunscreen).categories()).contains(ProductFunctionalCategory.UV_PROTECTION); + } + + @Test + @DisplayName("키가 비어 있으면 빈 목록이 아니라 예외다 — 조용히 '기능성 아님'이 되면 안 된다") + void missingKeyFailsLoudly() { + MfdsCatalogClient noKey = new MfdsCatalogClient(new FunctionalScreeningProperties()); + + assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> noKey.searchByItemName("구달")).getMessage()).contains("서비스 키"); + } +} diff --git a/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java b/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java new file mode 100644 index 0000000..00b330c --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java @@ -0,0 +1,94 @@ +package com.seoulection.admin.product.presentation; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.functional.domain.ClaimReading; +import com.seoulection.admin.product.functional.domain.FunctionalScreening; +import com.seoulection.admin.product.functional.domain.MfdsCandidate; +import com.seoulection.admin.product.functional.domain.MfdsItem; +import com.seoulection.admin.product.functional.domain.MfdsSource; +import com.seoulection.admin.product.functional.domain.ScreeningOutcome; +import com.seoulection.admin.product.presentation.controller.ProductController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 기능성 탭이 자동 조회 결과를 실제로 그려 내는지 본다. + * + *

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

첫 번째 테스트가 특히 중요하다 — 특성이 하나라도 붙은 행에서만 터지던 버그를 막는다. + * 특성이 비어 있으면 SpEL 선택식이 원소를 한 번도 평가하지 않아 오류가 드러나지 않는다. 그래서 + * 특성이 없는 개발 DB에서는 멀쩡하다가, 실제로 값을 채우는 순간 화면이 흰 화면이 됐다 + * (Thymeleaf가 출력을 이미 내보낸 뒤라 에러 페이지조차 못 그린다). + */ +@WebMvcTest(ProductController.class) +class IngredientRowsViewTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + ProductService service; + + @MockitoBean + FunctionalScreeningService screeningService; + + private void stubProduct() { + given(service.getProduct("p1")).willReturn(new ProductResult( + "p1", null, "Goodal Serum", "구달 청귤 비타C 잡티 세럼", "구달", "treatments", null, + null, null, null, 0, BigDecimal.ZERO, null, "PIPELINE", + List.of("나이아신아마이드", "청귤껍질추출물"), Map.of(), null, + List.of(), ProductStatus.NEED_MANUAL_REVIEW)); + given(service.propertyDefinitions()).willReturn(List.of( + new PropertyDefinitionResult("PURITY", "순도", "NUMBER", "%", "원료 순도"), + new PropertyDefinitionResult("FORM", "형태", "TEXT", null, "분말/액상 등"))); + } + + private ProductIngredientResult matched(long id, String rawName, List properties) { + return new ProductIngredientResult(id, "00000000-0000-0000-0000-000000000107", rawName, (int) id, + new BigDecimal("2.0"), new BigDecimal("2.0"), "%", null, + "Niacinamide", "나이아신아마이드", properties); + } + + private ProductIngredientResult unmatched(long id, String rawName) { + return new ProductIngredientResult(id, null, rawName, (int) id, null, null, null, null, + null, null, List.of()); + } + + @Test + @DisplayName("특성이 채워진 행도 렌더링된다 — 값이 입력 칸에 다시 찍힌다") + void rendersRowWithExistingProperties() throws Exception { + stubProduct(); + given(service.getProductIngredients("p1")).willReturn(List.of( + matched(3, "나이아신아마이드", List.of(new ProductIngredientProperty( + "PURITY", "순도", "NUMBER", null, + new BigDecimal("98.5"), new BigDecimal("99.9"), "%", null))))); + + mockMvc.perform(get("/admin/products/p1/workflow").param("step", "ingredients")) + .andExpect(status().isOk()) + .andExpect(content().string(Matchers.containsString("98.5"))) + .andExpect(content().string(Matchers.containsString("나이아신아마이드"))); + } + + @Test + @DisplayName("사전에 없는 성분은 입력 폼 대신 '보완하지 않는 성분'으로만 보인다") + void unmatchedIngredientsGetNoForm() throws Exception { + stubProduct(); + given(service.getProductIngredients("p1")).willReturn(List.of( + matched(3, "나이아신아마이드", List.of()), + unmatched(7, "청귤껍질추출물"))); + + String html = mockMvc.perform(get("/admin/products/p1/workflow").param("step", "ingredients")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + // 폼은 사전 연결된 행 하나만. ("/workflow/ingredients/complete" 는 보완 완료 버튼이라 제외) + long forms = html.lines() + .filter(line -> line.contains("/ingredients/") && !line.contains("/workflow/ingredients/")) + .count(); + org.assertj.core.api.Assertions.assertThat(forms).isEqualTo(1); + org.assertj.core.api.Assertions.assertThat(html).contains("/ingredients/3"); + org.assertj.core.api.Assertions.assertThat(html).doesNotContain("/ingredients/7"); + // 그래도 사라지지는 않는다 — 왜 입력 칸이 없는지 화면에서 알 수 있어야 한다. + org.assertj.core.api.Assertions.assertThat(html).contains("사전에 없어 보완하지 않는 성분"); + org.assertj.core.api.Assertions.assertThat(html).contains("청귤껍질추출물"); + } + + @Test + @DisplayName("사전 연결된 성분이 하나도 없으면 그 이유를 알려 준다") + void explainsWhenNothingIsLinked() throws Exception { + stubProduct(); + given(service.getProductIngredients("p1")).willReturn(List.of(unmatched(7, "청귤껍질추출물"))); + + mockMvc.perform(get("/admin/products/p1/workflow").param("step", "ingredients")) + .andExpect(status().isOk()) + .andExpect(content().string(Matchers.containsString("보완할 항목이 없습니다"))); + } +} diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 84d4029..d837218 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -5,6 +5,7 @@ import com.seoulection.admin.product.application.service.ProductService; import com.seoulection.admin.product.domain.enums.ProductStage; import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; import com.seoulection.admin.product.presentation.controller.ProductController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -41,6 +42,10 @@ class ProductControllerTest { @MockitoBean ProductService service; + /** 컨트롤러가 자동 조회를 부르지만 이 테스트가 보는 건 검수 폼의 규칙이다 — 대역으로 둔다. */ + @MockitoBean + FunctionalScreeningService screeningService; + @BeforeEach void stubList() { given(service.getProducts(any(), any(), anyInt(), anyInt())) diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java new file mode 100644 index 0000000..5f81c49 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductDetailViewTest.java @@ -0,0 +1,59 @@ +package com.seoulection.admin.product.presentation; + +import com.seoulection.admin.product.application.dto.ProductResult; +import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import com.seoulection.admin.product.functional.application.FunctionalScreeningService; +import com.seoulection.admin.product.presentation.controller.ProductController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 상세 화면이 "수집 전" 상태의 빈 제품에서도 렌더링되는지 본다. */ +@WebMvcTest(ProductController.class) +class ProductDetailViewTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + ProductService service; + + @MockitoBean + FunctionalScreeningService screeningService; + + @Test + @DisplayName("갓 등록해 아무것도 채워지지 않은 PENDING 제품의 상세도 열린다") + void rendersBarePendingProduct() throws Exception { + given(service.getProduct("p1")).willReturn(new ProductResult( + "p1", null, "ㅁㄴㅇㄹ", null, "ㅁㄴㅇㄹ", "toners", null, + null, null, null, 0, BigDecimal.ZERO, null, null, + null, null, null, List.of(), ProductStatus.PENDING)); + + mockMvc.perform(get("/admin/products/p1")).andExpect(status().isOk()); + } + + @Test + @DisplayName("파이프라인이 채운 값이 다 있는 제품의 상세도 열린다") + void rendersFullyPopulatedProduct() throws Exception { + given(service.getProduct("p2")).willReturn(new ProductResult( + "p2", "B01ABC", "Goodal Serum", "구달 청귤 세럼", "구달", "treatments", "설명", + new BigDecimal("19900"), "https://img", "https://shop", 12, + new BigDecimal("0.3"), new BigDecimal("1.2"), "ADMIN", + List.of("정제수"), Map.of("k", "v"), java.time.Instant.now(), + List.of(), ProductStatus.COMPLETE)); + + mockMvc.perform(get("/admin/products/p2")).andExpect(status().isOk()); + } +} From c8e849d094307dfa5a55879fe8bea514029bf39e Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 14:00:48 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor(admin):=20=EC=84=A4=EB=AC=B8=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=EB=A5=BC=20=ED=95=9C=20=EC=A4=84=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표에 textarea 를 넣으니 근거 하나가 서너 줄을 먹어, 정작 자주 쓰는 선택지 편집이 스크롤 아래로 밀렸다. 접힌 상태는 한 줄이고 펼쳐야 편집 칸이 나온다. 접힌 줄에도 이유를 미리 보여 준다(넘치면 말줄임) — 펼치지 않고도 무엇에 대한 근거인지 알 수 있어야 목록으로서 쓸모가 있다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../FunctionalScreeningProperties.java | 24 ++++-- .../FunctionalScreeningService.java | 13 ++- .../application/port/ScreeningTarget.java | 12 ++- .../product/functional/domain/ItemName.java | 21 +++++ .../functional/domain/MfdsCandidate.java | 15 ++++ .../FunctionalScreeningDocument.java | 4 +- .../JdbcProductIngredientAdapter.java | 85 ++++++++++++++++--- .../controller/ProductController.java | 34 +++++++- src/main/resources/application.yml | 5 +- src/main/resources/static/css/admin.css | 77 ++++++++++++++++- src/main/resources/static/js/admin.js | 32 +++++++ .../templates/fragments/ingredient-rows.html | 5 -- .../resources/templates/product-workflow.html | 3 +- src/main/resources/templates/survey.html | 70 +++++++-------- .../FunctionalScreeningServiceTest.java | 9 +- .../FunctionalWorkflowViewTest.java | 2 +- 16 files changed, 333 insertions(+), 78 deletions(-) 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 index 004d178..8b6445e 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -9,13 +9,15 @@ public class FunctionalScreeningProperties { private boolean enabled = true; /** - * 자동 판정 결과로 제품 상태를 실제로 옮길지. 기본은 켜짐 — 한글 이름을 저장하면 그 자리에서 - * 기능성까지 확정되고 어드민은 자동이 못 찾은 것만 손댄다. + * 자동 판정 결과로 제품 상태까지 바로 옮길지. 기본은 꺼짐. * - *

끄면 그림자 모드가 된다(판정은 기록하되 상태는 그대로). 판정 규칙을 크게 손본 뒤 - * 사람 판단과 대조해 보고 싶을 때 쓰라고 남겨 둔 스위치다. + *

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

켜면 판정이 끝나는 즉시 상태가 전진한다. 일치율을 충분히 확인한 뒤에 켤 스위치다. */ - private boolean applyDecisions = true; + private boolean applyDecisions = false; /** 이 점수 이상이면 LLM 판정 없이도 확정 후보가 된다(숫자·업체·유형 규칙은 그대로 통과해야 한다). */ private double autoThreshold = 0.95; @@ -23,8 +25,14 @@ public class FunctionalScreeningProperties { /** 이 점수 미만은 후보로도 남기지 않는다. 표를 후보로 채워 놓으면 검수가 더 느려진다. */ private double candidateThreshold = 0.60; - /** 화면에 남길 후보 수. */ - private int maxCandidates = 5; + /** + * 우리가 적은 이름이 등록명에 이 비율 이상 담겨 있으면, 유사도가 낮아도 후보로 남긴다. + * 제품명을 일부만 입력한 경우를 건지기 위한 값이다. + */ + private double coverageThreshold = 0.95; + + /** 화면에 남길 후보 수. 이름을 일부만 적으면 같은 계열이 여럿 걸려 넉넉해야 한다. */ + private int maxCandidates = 8; /** * "기능성 아님"까지 자동 확정할지. 기본은 꺼짐. @@ -45,6 +53,8 @@ public class FunctionalScreeningProperties { public void setAutoThreshold(double autoThreshold) { this.autoThreshold = autoThreshold; } public double getCandidateThreshold() { return candidateThreshold; } public void setCandidateThreshold(double candidateThreshold) { this.candidateThreshold = candidateThreshold; } + 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; } 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 index a0facd9..b32e8e9 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -82,6 +82,11 @@ public boolean isEnabled() { return properties.isEnabled(); } + /** 자동 판정이 상태까지 옮기는 모드인가. 화면 문구와 이동 경로가 이걸로 갈린다. */ + public boolean appliesDecisions() { + return properties.isApplyDecisions(); + } + /** * 자동화의 진입점. 어드민이 한글 이름을 저장한 직후에 불린다. * @@ -203,7 +208,7 @@ private FunctionalScreening decide(ScreeningTarget target) { } MfdsCandidate top = candidates.get(0); - if (top.confirmable(properties.getAutoThreshold())) { + if (top.confirmable(properties.getAutoThreshold()) && !top.partialNameMatch()) { return confirmed(target, candidates, 0, "등록명이 거의 일치합니다", brandCount); } @@ -326,7 +331,11 @@ private List rank(List pool, ScreeningTarget target, St continue; // 취하된 등록은 근거가 되지 못한다. } MfdsCandidate candidate = MfdsCandidate.of(item, query, brandKo, brandEntpName); - if (candidate.score() < properties.getCandidateThreshold()) { + // 점수가 낮아도, 우리가 적은 이름이 등록명에 통째로 들어 있으면 후보로 남긴다. + // 어드민이 "달바 워터풀"까지만 적은 경우가 여기다 — 유사도는 0.59라 잘리지만 + // 정작 맞는 제품이 그 안에 있다. + boolean covered = candidate.coverage() >= properties.getCoverageThreshold(); + if (candidate.score() < properties.getCandidateThreshold() && !covered) { continue; } String key = item.source() + "|" + ItemName.normalize(item.itemName()) + "|" + item.entpName(); 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 index 97b9d43..e34dc8a 100644 --- 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 @@ -27,9 +27,17 @@ public String displayName() { return nameKo == null || nameKo.isBlank() ? name : nameKo; } - /** 등록명은 대개 브랜드로 시작한다 — 비교 대상도 같은 모양으로 만든다. */ + /** + * 등록명은 대개 브랜드로 시작한다 — 비교 대상도 같은 모양으로 만든다. + * + *

⚠️ 이름이 이미 브랜드로 시작하면 붙이지 않는다. 어드민은 한글 이름을 "달바 워터풀"처럼 + * 브랜드까지 넣어 적는 경우가 많고, 그때 앞에 또 붙이면 "달바달바워터풀"로 조회돼 + * 한 건도 안 나온다. + */ public String brandedName(String brandKo) { String prefix = brandKo == null || brandKo.isBlank() ? brand : brandKo; - return prefix + displayName(); + 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/ItemName.java b/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java index 8a2ab1e..97c3034 100644 --- a/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java +++ b/src/main/java/com/seoulection/admin/product/functional/domain/ItemName.java @@ -43,6 +43,27 @@ public static double similarity(String left, String right) { 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(); + } + /** * 이름 안의 숫자 토큰. 자동 확정을 막는 하드 룰의 근거다. * 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 index e075e62..5eee7be 100644 --- a/src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java +++ b/src/main/java/com/seoulection/admin/product/functional/domain/MfdsCandidate.java @@ -9,6 +9,7 @@ public record MfdsCandidate( MfdsItem item, double score, + double coverage, ClaimReading claims, boolean numericMatch, boolean brandMatch @@ -18,6 +19,7 @@ public static MfdsCandidate of(MfdsItem item, String queryName, String brand, St 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)); @@ -28,6 +30,16 @@ 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) { @@ -39,6 +51,9 @@ public String blockReason() { if (!claims.autoConfirmable()) { return claims.reason(); } + if (partialNameMatch()) { + return "제품명을 일부만 입력해 같은 계열이 여러 건 걸립니다 — 맞는 것을 골라 주세요"; + } return ""; } } 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 index 707a82e..c07b6a2 100644 --- a/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/FunctionalScreeningDocument.java @@ -90,6 +90,7 @@ public static class Candidate { @Field("report_date") private String reportDate; private double score; + private double coverage; private List claims; @Field("numeric_match") private boolean numericMatch; @@ -111,6 +112,7 @@ static Candidate fromDomain(MfdsCandidate candidate) { row.targetFlagName = item.targetFlagName(); row.reportDate = item.reportDate(); row.score = candidate.score(); + row.coverage = candidate.coverage(); row.claims = candidate.claims().categories(); row.numericMatch = candidate.numericMatch(); row.brandMatch = candidate.brandMatch(); @@ -122,7 +124,7 @@ static Candidate fromDomain(MfdsCandidate candidate) { MfdsCandidate toDomain() { MfdsItem item = new MfdsItem(MfdsSource.valueOf(source), itemName, entpName, null, eeName, spf, pa, targetFlagName, reportDate, false); - return new MfdsCandidate(item, score, + return new MfdsCandidate(item, score, coverage, new ClaimReading(claims == null ? List.of() : claims, outOfScope, derivable), numericMatch, brandMatch); } diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java index 5a30e59..473a523 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/JdbcProductIngredientAdapter.java @@ -30,25 +30,79 @@ public class JdbcProductIngredientAdapter implements ProductIngredientPort { 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; - 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; - jdbc.update("insert into product_ingredient(product_id, ingredient_id, raw_name, inci_order, source) values (?, ?, ?, ?, ?)", - productId, matchIngredient(rawName), rawName, i + 1, source == null ? "ADMIN" : source); + 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. 표기가 흔들리므로(WATER/Water/Aqua) 대소문자를 무시하고 별칭까지 본다. */ + /** + * 원문 → 성분 사전 id. + * + *

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

공백을 지우고 비교한다 — "히알루론산 나트륨"과 "히알루론산나트륨"은 같은 성분이다. + */ private String matchIngredient(String rawName) { - return 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); }, + 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); } @@ -89,8 +143,13 @@ public List findByProductId(String productId) { @Transactional public void review(String productId, long rowId, String ingredientId, BigDecimal min, BigDecimal max, String unit, String notes, List properties) { - int changed = jdbc.update("update product_ingredient set ingredient_id=?, concentration_min=?, concentration_max=?, concentration_unit=?, notes=? where id=? and product_id=?", - blank(ingredientId), min, max, blank(unit), blank(notes), rowId, productId); + // ⚠️ 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; // 미지정 = 특성은 건드리지 않는다 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 abb0b93..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 @@ -325,19 +325,47 @@ public String workflowScreen(@PathVariable String id, @RequestParam(required = f } FunctionalScreening screening = screened.get(); - if (screening.outcome().decided()) { + 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(); @@ -432,7 +460,9 @@ public String workflowPage(@PathVariable String id, if ("functional".equals(resolved)) { // 한글 이름이 이미 있으면 화면을 여는 것만으로 자동 조회가 한 번 돈다. 없으면 // 조회할 근거가 없으니 아무것도 하지 않고 입력 칸만 보여 준다. - model.addAttribute("screening", screeningService.findOrScreen(id).orElse(null)); + var screening = screeningService.findOrScreen(id).orElse(null); + model.addAttribute("screening", screening); + prefillFromScreening(request, product, screening); } return "product-workflow"; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 576f990..0ac9337 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,8 +22,9 @@ spring: admin: functional-screening: enabled: ${FUNCTIONAL_SCREENING_ENABLED:true} - # 자동 확정을 실제 상태 전진(READY_FOR_INCIAPI)으로 옮길지. 끄면 그림자 모드(판정만 기록). - apply-decisions: true + # 자동 판정이 상태까지 바로 옮길지. 기본은 꺼짐 — 조회는 근거를 모아 폼을 채워 두고, + # 확정은 어드민이 저장을 눌러 한다. 일치율을 충분히 본 뒤에 켤 스위치다. + apply-decisions: ${FUNCTIONAL_SCREENING_APPLY:false} auto-threshold: 0.95 # 이 점수 이상이면 LLM 판정 없이도 확정 후보 candidate-threshold: 0.60 # 이 미만은 후보로도 남기지 않는다 max-candidates: 5 diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index 0eac88f..16f8f76 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -1085,7 +1085,78 @@ td a:hover { text-decoration: underline; } -/* 근거 표 — 선택지 표와 같은 결. 이유(rationale)는 폭을 채워 두 줄로 읽히게 한다. */ -.evidence-table td { vertical-align: top; } -.evidence-form textarea { width: 100%; min-width: 260px; resize: vertical; } + + + +/* ───────────────────────────────────────────────────────────────────────────── + 외부 호출 대기 표시. + + 의약품안전나라 조회는 브랜드 전수(최대 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; } 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 index 565d7ca..8143a91 100644 --- a/src/main/resources/templates/fragments/ingredient-rows.html +++ b/src/main/resources/templates/fragments/ingredient-rows.html @@ -53,11 +53,6 @@

성분별 보완 -
- - -
-
한글 이름으로 자동 조회
@@ -252,5 +252,6 @@

} })(); + diff --git a/src/main/resources/templates/survey.html b/src/main/resources/templates/survey.html index d45a63a..172a043 100644 --- a/src/main/resources/templates/survey.html +++ b/src/main/resources/templates/survey.html @@ -55,42 +55,42 @@

설문 관리

-
-

- - - - - - - - - - -
종류근거 · 묻는 이유관리
논문 - - - - - - - - - - 원문 -
- -
-
+
+
+ + 논문 + 제목 + 묻는 이유 + +
+

묻는 이유

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

근거가 없습니다. 이 문항을 왜 묻는지 남겨 두면 나중에 고치거나 뺄 때 판단할 수 있습니다. diff --git a/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java b/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java index 80f7c89..c8c3389 100644 --- a/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java +++ b/src/test/java/com/seoulection/admin/product/functional/application/FunctionalScreeningServiceTest.java @@ -73,8 +73,9 @@ private MfdsItem item(String itemName, String entpName, String eeName, String sp } @Test - @DisplayName("등록명이 거의 일치하면 자동 확정하고 상태까지 옮긴다") + @DisplayName("적용 모드를 켜면 자동 확정이 상태까지 옮긴다") void confirmsAndAdvancesStatus() { + properties.setApplyDecisions(true); catalog.brand("구달", List.of( item("구달청귤비타씨잡티세럼", "(주)클리오", "피부의 미백에 도움을 준다. 피부의 주름개선에 도움을 준다.", null, null))); @@ -122,6 +123,7 @@ void autoConcludesNoneOnlyWhenEnabled() { assertThat(service().screen(target).outcome()).isEqualTo(ScreeningOutcome.NOT_MATCHED); properties.setAutoConcludeNone(true); + properties.setApplyDecisions(true); FunctionalScreening screening = service().screen(target); assertThat(screening.outcome()).isEqualTo(ScreeningOutcome.AUTO_NONE); assertThat(screening.claims()).isEmpty(); @@ -141,9 +143,8 @@ void keepsFailuresOutOfTheDecision() { } @Test - @DisplayName("그림자 모드에서는 판정만 기록하고 상태는 그대로 둔다") - void shadowModeRecordsWithoutAdvancing() { - properties.setApplyDecisions(false); + @DisplayName("기본값에서는 판정만 기록하고 상태는 어드민이 확정한다") + void doesNotAdvanceByDefault() { catalog.brand("구달", List.of( item("구달청귤비타씨잡티세럼", "(주)클리오", "피부의 미백에 도움을 준다.", null, null))); diff --git a/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java b/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java index 00b330c..822d76c 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/FunctionalWorkflowViewTest.java @@ -84,7 +84,7 @@ private FunctionalScreening screening() { MfdsItem item = new MfdsItem(MfdsSource.REPORT, "닥터디퍼런트131모이스처라이저", "주식회사다른코스메틱스", "2", "피부의 주름개선에 도움을 준다.", null, null, "제10조 제1항 제1호", "20230405", false); - MfdsCandidate candidate = new MfdsCandidate(item, 0.94, + MfdsCandidate candidate = new MfdsCandidate(item, 0.94, 0.94, new ClaimReading(List.of(ProductFunctionalCategory.WRINKLE_IMPROVEMENT), false, true), false, true); return new FunctionalScreening("p1", ScreeningOutcome.NEEDS_REVIEW, List.of(), From bc213f18e0c898cdd2bca8f6549b0a37cf8292a6 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 15:58:14 +0900 Subject: [PATCH 14/16] =?UTF-8?q?feat(admin):=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=84=B1=20=EC=9E=90=EB=8F=99=20=ED=8C=90=EC=A0=95=EC=9D=B4=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=20=EB=93=B1=EB=A1=9D=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=ED=9B=84=EB=B3=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=B0=BE=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판정에 임계값을 통과한 후보만 넘기고 있었다. 유사도는 표기가 크게 다른 제품을 통째로 떨어뜨려서, "토리든 다이브인 저분자 히알루론산 세럼"은 그 브랜드 등록 2건과 모두 0.5 미만이라 판정이 아예 호출되지 않았고 화면에는 "후보 없음"만 떴다. 어드민이 직접 판단할 근거가 화면에 하나도 남지 않는다. 이제 점수는 정렬에만 쓰고, 브랜드 등록 목록을 점수순으로 넉넉히(기본 20건) 넘겨 고르는 일을 판정에 맡긴다. 실제로 "Beauty of Joseon 릴리프 선"이 유사도 0.36 으로 꼴찌였던 '조선미녀맑은쌀선크림'에 걸렸다 — 유사도만 봤으면 못 찾았을 건이다. 함께 고친 것들: - 브랜드 별칭을 첫 성공에서 멈추지 않고 전부 조회해 합친다. 표기가 갈리는 브랜드가 있고, 한 표기로 몇 건 나왔다고 나머지를 안 보면 정작 맞는 제품을 놓친다. - 이름을 일부만 적어도 후보가 남는다. "달바 워터풀"은 등록명과 유사도 0.59 라 잘렸는데, 정작 우리가 적은 이름은 등록명에 통째로 들어 있다. coverage 로 그런 건을 건지되 자동 확정에는 쓰지 않는다 — 같은 계열 다섯 건이 모두 1.0 이라 고를 근거가 못 된다. - 브랜드가 이름 앞에 두 번 붙던 문제. "달바" + "달바 워터풀" 이 "달바달바워터풀" 로 조회돼 한 건도 안 나왔다. - 브랜드 표기 프롬프트를 실제로 갈리는 축으로 바꿨다. 법인 표기는 넣지 않는다 — 등록명 19만 건 중 "(주)" 4건, "주식회사" 5건뿐이라 붙이면 오히려 0건이 된다. - Mongo 한글 이름 필드를 name_ko → name_kr 로. 같은 컬렉션에 쓰는 product-service 도 함께 바꿔야 하고, 배포 전에 기존 문서에 $rename 을 돌려야 한다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01146dyT7CQcWMzkCFpkPPj4 --- .../FunctionalScreeningProperties.java | 20 ++++ .../FunctionalScreeningService.java | 93 +++++++++++++------ .../GeminiProductNameResolver.java | 23 +++-- .../document/ProductDocument.java | 2 +- src/main/resources/application.yml | 2 +- .../resources/templates/product-workflow.html | 7 ++ 6 files changed, 110 insertions(+), 37 deletions(-) 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 index 8b6445e..943afa8 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -34,6 +34,20 @@ public class FunctionalScreeningProperties { /** 화면에 남길 후보 수. 이름을 일부만 적으면 같은 계열이 여럿 걸려 넉넉해야 한다. */ private int maxCandidates = 8; + /** + * 판정에 넘길 등록 건 수. 화면에 남기는 수보다 넉넉하게 둔다. + * + *

유사도는 표기가 크게 다른 제품을 통째로 떨어뜨린다. 고르는 일을 판정에 맡기려면 + * 임계값을 통과한 것만이 아니라 브랜드 등록 목록을 그대로 보여 줘야 한다. + */ + private int judgePoolSize = 20; + + /** 브랜드 한글 표기 후보 수. 표기가 갈리는 브랜드가 있어 전부 조회해 합친다. */ + private int maxBrandAliases = 4; + + /** 등록명 검색어 상한. 상한은 안전나라 쿼터 때문이고, 많을수록 회수율은 올라간다. */ + private int maxSearchTerms = 8; + /** * "기능성 아님"까지 자동 확정할지. 기본은 꺼짐. * @@ -53,6 +67,12 @@ public class FunctionalScreeningProperties { 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; } 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 index b32e8e9..ed9d267 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -183,12 +183,19 @@ private FunctionalScreening decide(ScreeningTarget target) { String brandKo = aliases.isEmpty() ? target.brand() : aliases.get(0); // 브랜드 전수: 등록 목록 + 업체명 기준값 + "0건"이라는 음성 근거를 한 번에 준다. - List brandItems = List.of(); + // + // ⚠️ 첫 별칭에서 멈추지 않는다. 표기가 갈리는 브랜드가 실제로 있고("아누아"/"어누아", + // "넘버즈인"/"넘버즈인"), 한 표기로 몇 건 나왔다고 나머지를 안 보면 정작 맞는 제품이 + // 다른 표기 아래 있을 때 통째로 놓친다. 별칭마다 조회해 합친다. + List brandItems = new ArrayList<>(); + int bestHits = 0; for (String alias : aliases) { - brandItems = mfdsCatalog.searchBrand(alias); - if (!brandItems.isEmpty()) { + List found = mfdsCatalog.searchBrand(alias); + brandItems.addAll(found); + // 이름 비교의 기준이 될 표기는 가장 많이 걸린 것으로 둔다 — 그게 실제 등록 표기다. + if (found.size() > bestHits) { + bestHits = found.size(); brandKo = alias; - break; } } String brandEntpName = dominantEntpName(brandItems); @@ -198,36 +205,62 @@ private FunctionalScreening decide(ScreeningTarget target) { pool.addAll(mfdsCatalog.searchByItemName(term)); } - List candidates = rank(pool, target, brandKo, brandEntpName); long brandCount = brandItems.size(); - - if (candidates.isEmpty()) { + List scored = score(pool, target, brandKo, brandEntpName); + if (scored.isEmpty()) { // 브랜드 전수를 한글 표기로 못 돌렸으면 "0건"이 음성 근거가 되지 못한다 — // 등록명은 전부 한글이라 영문 브랜드명으로 조회하면 무조건 0건이 나온다. return noCandidate(target, brandCount, containsHangul(brandKo)); } - MfdsCandidate top = candidates.get(0); - if (top.confirmable(properties.getAutoThreshold()) && !top.partialNameMatch()) { - return confirmed(target, candidates, 0, "등록명이 거의 일치합니다", brandCount); + // 유사도만으로 확실한 것들. 여기서 끝나면 판정을 부르지 않는다(호출 비용을 아낀다). + List strong = scored.stream() + .filter(candidate -> candidate.score() >= properties.getCandidateThreshold() + || candidate.coverage() >= properties.getCoverageThreshold()) + .limit(properties.getMaxCandidates()) + .toList(); + + if (!strong.isEmpty() && strong.get(0).confirmable(properties.getAutoThreshold()) + && !strong.get(0).partialNameMatch()) { + return confirmed(target, strong, 0, "등록명이 거의 일치합니다", brandCount); } - // 점수로 못 끝낸 경우에만 판정을 부른다 — 유사도가 못 거르는 것들이 여기 남는다. - CandidateVerdict verdict = nameResolver.judge(target, candidates.stream().map(MfdsCandidate::item).toList()); + // ⚠️ 판정에 임계값을 통과한 것만 넘기지 않는다. 유사도는 표기가 크게 다른 제품을 + // 통째로 떨어뜨린다 — "토리든 다이브인 저분자 히알루론산 세럼"은 이 브랜드 등록 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()) { - return new FunctionalScreening(target.id(), ScreeningOutcome.NEEDS_REVIEW, List.of(), candidates, -1, - verdict.confidence(), "같은 제품으로 볼 후보가 없습니다: " + verdict.reason(), + // 판정이 "없다"고 했다. 유사도 높은 후보가 있었으면 사람이 다시 볼 값어치가 있고, + // 그것마저 없으면 이 브랜드에 이 제품의 등록이 없다는 쪽에 가깝다. 어느 쪽이든 + // 무엇을 보고 그렇게 판단했는지는 화면에 남긴다. + 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), candidates.size() - 1); - MfdsCandidate chosen = candidates.get(index); + 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); + if (verdict.high() && chosen.numericMatch() && chosen.brandMatch() && chosen.claims().autoConfirmable()) { - return confirmed(target, candidates, index, verdict.reason(), brandCount); + return confirmed(target, withChosen, 0, verdict.reason(), brandCount); } String block = chosen.blockReason(); return new FunctionalScreening(target.id(), ScreeningOutcome.NEEDS_REVIEW, chosen.claims().categories(), - candidates, index, verdict.confidence(), + withChosen, 0, verdict.confidence(), block.isBlank() ? "판정 신뢰도가 낮아 확인이 필요합니다: " + verdict.reason() : block, brandCount, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); } @@ -291,7 +324,8 @@ private List brandAliases(String brand) { if (!aliases.contains(key)) { aliases.add(key); } - return aliases.stream().filter(value -> value != null && !value.isBlank()).distinct().limit(3).toList(); + return aliases.stream().filter(value -> value != null && !value.isBlank()) + .distinct().limit(properties.getMaxBrandAliases()).toList(); }); } @@ -303,7 +337,9 @@ private List searchTerms(ScreeningTarget target, String brandKo) { .map(ItemName::normalize) .forEach(terms::add); terms.removeIf(term -> term.length() < 2); - return terms.stream().limit(5).toList(); + // 상한을 두는 건 안전나라 쿼터 때문이지 정확도 때문이 아니다 — 후보가 많을수록 회수율은 + // 올라간다. 기본 8개는 "정규화 2 + 모델이 만든 표기 변형 여러 개"를 담는 크기다. + return terms.stream().limit(properties.getMaxSearchTerms()).toList(); } /** @@ -323,7 +359,14 @@ private String dominantEntpName(List brandItems) { .orElse(null); } - private List rank(List pool, ScreeningTarget target, String brandKo, String brandEntpName) { + /** + * 취하 건을 걸러 내고 중복을 합친 뒤 점수순으로 세운다. 임계값은 보지 않는다. + * + *

거르는 일을 여기서 하지 않는 이유: 유사도는 표기가 크게 다른 제품을 통째로 떨어뜨린다. + * 무엇을 후보로 볼지는 호출하는 쪽이 정한다. + */ + private List score(List pool, ScreeningTarget target, + String brandKo, String brandEntpName) { String query = target.brandedName(brandKo); Map unique = new LinkedHashMap<>(); for (MfdsItem item : pool) { @@ -331,13 +374,6 @@ private List rank(List pool, ScreeningTarget target, St continue; // 취하된 등록은 근거가 되지 못한다. } MfdsCandidate candidate = MfdsCandidate.of(item, query, brandKo, brandEntpName); - // 점수가 낮아도, 우리가 적은 이름이 등록명에 통째로 들어 있으면 후보로 남긴다. - // 어드민이 "달바 워터풀"까지만 적은 경우가 여기다 — 유사도는 0.59라 잘리지만 - // 정작 맞는 제품이 그 안에 있다. - boolean covered = candidate.coverage() >= properties.getCoverageThreshold(); - if (candidate.score() < properties.getCandidateThreshold() && !covered) { - continue; - } String key = item.source() + "|" + ItemName.normalize(item.itemName()) + "|" + item.entpName(); unique.merge(key, candidate, (existing, incoming) -> existing.claims().categories().size() >= incoming.claims().categories().size() @@ -345,7 +381,6 @@ private List rank(List pool, ScreeningTarget target, St } return unique.values().stream() .sorted(Comparator.comparingDouble(MfdsCandidate::score).reversed()) - .limit(properties.getMaxCandidates()) .toList(); } diff --git a/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java b/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java index ce6b931..4c773b1 100644 --- a/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java +++ b/src/main/java/com/seoulection/admin/product/functional/infrastructure/GeminiProductNameResolver.java @@ -83,15 +83,24 @@ private static Map stringArraySchema(String field) { @Override public List koreanBrandAliases(String brand) { String prompt = """ - 한국 화장품 브랜드의 한글 표기를 알려 주세요. + 한국 화장품 브랜드가 식약처 기능성화장품 등록명에 어떤 한글로 적히는지 알려 주세요. 브랜드: %s - 의약품안전나라(식약처) 기능성화장품 등록 목록에서 이 브랜드 제품이 어떤 한글 표기로 - 올라가 있을지, 가능성이 높은 순으로 최대 3개 적어 주세요. - 예: Goodal → 구달 / d'Alba → 달바 / Round Lab → 라운드랩 + 등록명(ITEM_NAME)은 "브랜드한글표기 + 제품명"이 공백 없이 붙은 형태입니다. + 예: 구달청귤비타씨잡티세럼 / 라운드랩자작나무수분선크림 / 조선미녀윤결수면팩 - - 한글만 적습니다. 법인명(주식회사 ○○)이 아니라 브랜드 표기입니다. + 표기가 갈릴 수 있으니 가능성이 높은 순으로 최대 4개 적어 주세요. 특히 이런 축으로: + - 음차 표기 그대로: Goodal → 구달, d'Alba → 달바, Torriden → 토리든 + - 국내 유통 브랜드명: Beauty of Joseon → 조선미녀 (뷰티오브조선이 아님) + - 음차가 갈리는 경우: numbuzin → 넘버즈인 / 넘버스인 + - 숫자·영문이 섞인 경우: SKIN1004 → 스킨1004 / 스킨천사 + + 규칙: + - 한글(숫자 포함)만 적습니다. 공백·기호는 넣지 마세요. + - 법인명은 절대 넣지 마세요. "(주)", "주식회사", "○○코리아" 같은 표기는 + 등록명에 거의 나오지 않습니다(전체 19만여 건 중 (주) 4건, 주식회사 5건). + 업체명은 조회가 아니라 응답의 ENTP_NAME으로 따로 확인합니다. - 모르면 빈 배열을 주세요. 지어내지 마세요. """.formatted(brand); @@ -119,7 +128,9 @@ public List registrationNameCandidates(ScreeningTarget target, String br 규칙: - 브랜드 한글 표기로 시작하는 형태를 반드시 포함하세요. - SPF/PA 같은 대괄호 표기와 용량은 뺍니다. - - 표기가 갈릴 수 있는 부분(비타C/비타씨 등)은 각각 따로 넣어 최대 5개까지. + - 표기가 갈릴 수 있는 부분(비타C/비타씨, 10%%/10퍼센트 등)은 각각 따로 넣어 최대 6개까지. + - 제품명이 길면 앞부분만 자른 형태도 하나 넣으세요 — 등록명 검색은 부분 일치라 + 뒤에 붙는 제형어가 달라도 걸립니다(예: 구달청귤비타씨). - 제품명에 있는 숫자는 절대 바꾸지 마세요. """.formatted(target.brand(), brandKo, target.displayName()); 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 1101c9f..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,7 +25,7 @@ public class ProductDocument { private String asin; private String name; - @Field("name_ko") + @Field("name_kr") private String nameKo; private String brand; private String category; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0ac9337..65b6be7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,7 +27,7 @@ admin: apply-decisions: ${FUNCTIONAL_SCREENING_APPLY:false} auto-threshold: 0.95 # 이 점수 이상이면 LLM 판정 없이도 확정 후보 candidate-threshold: 0.60 # 이 미만은 후보로도 남기지 않는다 - max-candidates: 5 + max-candidates: 8 # 이름을 일부만 적으면 같은 계열이 여럿 걸린다 # "기능성 아님"까지 자동 확정할지. 검색 실패와 구분이 어려워 기본은 꺼 둔다 — # 브랜드 등록이 0건일 때만 켤 만하고, 선크림처럼 법적으로 기능성이어야 하는 건 제외된다. auto-conclude-none: false diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index 05faec1..8b5b23c 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -127,6 +127,10 @@

+

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

@@ -154,9 +158,12 @@

From 9df5dde0b2d65830169153edb3ab0d30567fdb33 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 17:41:34 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat(admin):=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=84=B1=20=EC=9E=90=EB=8F=99=20=ED=99=95=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EC=9D=84=20=EC=A2=81=ED=9E=88=EA=B3=A0=20AI=20?= =?UTF-8?q?=EC=A7=80=EB=AA=A9=EC=9D=84=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=93=9C=EB=9F=AC=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 확정 판정이 골랐다고 확정하지 않는다. 이름이 완전히 같은 경우를 빼면 확정은 사람 몫이다 — 모델이 고른 근거가 아무리 그럴듯해도 등록명이 다르다는 건 다른 제품일 수 있다는 뜻이고, 기능성은 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 것보다 크다. 대신 고른 것을 맨 앞에 세우고 유형까지 채워 둬 맞으면 저장만 누르면 되게 한다. 점수 하나로도 확정되지 않는다 — 숫자 토큰 일치·업체명 연결·기능성 유형 도출을 모두 통과해야 한다. 실측에서 "닥터디퍼런트 311 모이스처라이저"가 등록명 "닥터디퍼런트131 모이스처라이저"와 0.94 였는데 숫자 규칙에서 걸린다. ## 화면 전에는 .is-selected 클래스만 붙이고 스타일을 안 만들어서 모델이 고른 것이 화면에 전혀 드러나지 않았다. 후보가 여덟 줄 깔린 표에서 어느 것을 보라는 건지 알 수 없으면 자동 조회를 돌린 의미가 없다. 배경·좌측 바에 더해 "AI 지목" 칩으로 글자로도 남긴다 — 클래스만으로는 스타일이 빠지면 아무것도 안 보인다. 설정값(coverage-threshold·judge-pool-size·max-brand-aliases·max-search-terms)에 각각 무엇을 조절하는지 주석을 붙였다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- docs/functional-screening.md | 397 ++++++++++-------- .../FunctionalScreeningProperties.java | 9 +- .../FunctionalScreeningService.java | 14 +- src/main/resources/application.yml | 6 +- src/main/resources/static/css/admin.css | 20 + .../resources/templates/product-workflow.html | 5 + 6 files changed, 275 insertions(+), 176 deletions(-) diff --git a/docs/functional-screening.md b/docs/functional-screening.md index bdb1d6a..bef06c5 100644 --- a/docs/functional-screening.md +++ b/docs/functional-screening.md @@ -1,286 +1,345 @@ -# 기능성 검수 자동화 (Functional Screening) +# 기능성 자동 판정 (Functional Screening) -지금 어드민의 "기능성 확인" 탭은 100% 수동이다. 사람이 의약품안전나라를 직접 검색해 기능성 -여부를 판단하고 유형을 고른다. 이 문서는 그 판단을 자동으로 시도하고, **자동으로 확신할 수 -없을 때만 사람에게 넘기는** 구조를 설명한다. +어드민의 "기능성 확인" 탭에서 **한글 이름을 저장하면** 그 자리에서 의약품안전나라를 조회해 +후보와 기능성 유형을 채워 둔다. 어드민은 근거를 보고 고르거나, 아니면 직접 입력한다. -**동작 흐름은 한 줄이다.** 기능성 확인 탭에서 *한글 이름을 저장하면* 그 자리에서 안전나라를 -조회해 기능성까지 기록하고 다음 건으로 넘어간다. 어드민이 손대는 건 자동이 확정하지 못한 -제품뿐이다. +목표는 "수동을 없애는 것"이 아니라 **사람이 검색하는 시간을 없애는 것**이다. 기능성은 규제 +정보라서 틀린 자동 확정이 비어 있는 큐보다 훨씬 비싸다. 한글 이름이 트리거인 이유: 안전나라 등록명(`ITEM_NAME`)은 전부 한글이고 브랜드 한글 표기로 시작한다("구달청귤비타씨잡티세럼"). 영문 제품명만으로는 조회가 시작조차 안 되므로, 한글 이름이 채워지는 그 순간이 자동 조회가 가장 잘 듣는 시점이다. -목표는 "수동을 없애는 것"이 아니라 **수동 큐를 줄이는 것**이다. 기능성은 규제 정보라서 -틀린 자동 확정은 비어 있는 큐보다 훨씬 비싸다. - --- -## 0. 먼저, 처음 구상과 달라진 점 (실측) +## 1. API 제약 — 실측 -원래 구상은 "한글 제품명 → LLM이 실제 법인명(주식회사 ○○)을 찾음 → **그 회사명으로** -안전나라 API 호출"이었다. 실제로 두 API를 찔러 확인한 결과 **이 경로는 성립하지 않는다.** +두 서비스를 쓴다. 전체 건수는 보고 195,220건 / 심사 20,629건이고, 필터가 실제로 먹는지는 +건수가 줄어드는지로 알 수 있다. -전체 건수는 보고 195,220건 / 심사 20,629건이다. 필터가 실제로 먹는지는 건수가 줄어드는지로 -알 수 있다. +- 보고: `https://apis.data.go.kr/1471000/FtnltCosmRptPrdlstInfoService/getRptPrdlstInq` +- 심사: `https://apis.data.go.kr/1471057/FtnltCosmSrngPrdlstInfoService04/getSrngPrdlstInq` -| 파라미터 | 보고(1471000) | 심사(1471057) | 동작 | +| 파라미터 | 보고 | 심사 | 동작 | |---|---|---|---| | `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` | 195,220 | 20,629 | ❌ 무시 | -| `bizrno` (사업자번호) | 195,220 | 20,629 | ❌ 무시 | -| `ee_code` (효능코드) | 195,220 | — | ❌ 무시 | -| `report_flag_code` | 195,220 | — | ❌ 무시 | -| `dept_receipt_no` | 195,220 | — | ❌ 무시 | +| `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건, 그나마 제품명 뒤 괄호): -- 조회 키로 쓰지 않는다 (불가능). -- 대신 **응답에 들어 있는 `ENTP_NAME`을 검증 신호로 쓴다.** 안전나라가 직접 알려주는 - 값이므로 LLM이 기억해 낸 법인명보다 정확하다. LLM이 "구달은 클리오"라고 답할 필요가 - 없다 — `item_name=구달`로 193건을 받아 보면 전부 `(주)클리오`라고 API가 말해 준다. +``` +토리든 1건 / 아누아 84건 / 조선미녀 51건 / 클리오 363건 +(주)토리든·토리든주식회사·주식회사토리든·"토리든 주식회사" → 네 브랜드 모두 전부 0건 +``` -**LLM이 실제로 필요한 자리는 따로 있다.** 아래 3절. +법인명은 조회가 아니라 **응답의 `ENTP_NAME`으로 검증**하는 데만 쓴다. API가 직접 알려주므로 +모델이 추측할 이유가 없다. --- -## 1. 왜 지금 "상품 이름으로만 검색하면 안 나오는가" +## 2. 왜 제품명으로만 검색하면 안 나오는가 -안전나라의 `ITEM_NAME`은 **유통명이 아니라 등록명**이고, 공백·기호가 전부 제거된 형태다. +`ITEM_NAME`은 유통명이 아니라 **등록명**이고, 공백·기호가 전부 빠진 한 덩어리다. | 우리가 가진 이름 | 안전나라 등록명 | |---|---| | 구달 청귤 비타C 잡티 세럼 | `구달청귤비타씨잡티세럼` | | 폴라초이스 10% 나이아신아마이드 부스터 | `폴라초이스10퍼센트나이아신아마이드부스터` | | 뉴트로지나 딥클린 포밍 클렌저 | `뉴트로지나딥클린아크네포밍클렌저(보)` | -| 달바 워터풀 톤업 선크림 [SPF50+/PA++++] | `달바워터풀톤업선크림` | - -`비타C→비타씨`, `10%→10퍼센트`, 중간에 끼어드는 `아크네`, 뒤에 붙는 `(보)`. 공백만 지우는 -정규화로는 못 넘는 벽이고, 이게 지금 검색이 안 되는 이유의 대부분이다. +| Beauty of Joseon 릴리프 선 | `조선미녀맑은쌀선크림` | -기존 파이썬 스크립트(`crawlers/glowpick/check_functional_cosmetics.py`) 결과로 실측하면: +`비타C→비타씨`, `10%→10퍼센트`, 중간에 끼는 `아크네`, 뒤에 붙는 `(보)`. 한 글자 차이로 0건이 +된다: ``` -제품 83건 - 후보 0건 48건 (58%) ← 검색 실패 - 최고 유사도 ≥ 0.85 29건 (35%) ← 자동 처리 가능 +'구달청귤비타C잡티세럼' → 0건 '구달청귤비타씨잡티세럼' → 2건 ✅ +'폴라초이스10%나이아신…' → 0건 '폴라초이스10퍼센트나이아신아마이드부스터' → 1건 ✅ ``` -**58%가 검색 실패**다. 이걸 그대로 "기능성 아님"으로 확정하면 안 된다. +기존 파이썬 스크립트 결과로 실측하면 **83건 중 48건(58%)이 검색 실패**였다. --- -## 2. 핵심 전략: 브랜드 전수 조회 +## 3. 핵심 전략 — 브랜드 전수 조회 -`item_name`은 부분 일치(LIKE)로 동작한다. 제품명 전체가 아니라 **브랜드 한글명만** 넣으면 -그 브랜드의 등록 목록 전체가 나온다. 그 안에서 제품을 찾으면 등록명 표기 차이를 통과한다. +`item_name`은 부분 일치다. 브랜드 한글명만 넣으면 그 브랜드 등록 목록이 통째로 나온다. -미매칭 12건에 브랜드 전수 조회를 실제로 돌린 결과: +미매칭 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건 → 브랜드 자체가 DB에 없음 ⇒ 기능성 아님 근거 -[꼬달리] 뷰티 엘릭시르 brand총 0건 → 브랜드 자체가 DB에 없음 ⇒ 기능성 아님 근거 +[뉴트로지나] 딥클린 포밍 클렌저 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건"은 강한 음성 근거** — 단, 한글 표기로 조회했을 때만이다. -1. **회수율이 올라간다** — 직접 검색이 실패한 제품이 브랜드 전수에서 발견된다. -2. **유사도만 믿으면 안 된다** — `311 vs 131` (0.94), `77 vs 70` (0.64)처럼 - **숫자 한 자리가 다른 제품**이 높은 점수를 받는다. 이건 규칙으로 막아야 한다. -3. **"브랜드 총 0건"은 강한 음성 근거다** — 해외 브랜드가 국내 기능성 등록을 하지 않은 - 경우이고, 이때만 "기능성 아님"을 자동 확정할 근거가 생긴다. +> ⚠️ 화면의 "브랜드 등록 N건"은 엄밀히는 *`item_name=브랜드명`으로 부분 일치한 건수*다. +> 브랜드명이 등록명에 안 들어간 제품은 빠지고, 다른 제품명 중간에 우연히 들어가면 섞인다 +> (`어누아` → `이시오에프리미엄리페**어누아**비비크림`). 법인 기준 등록 수와도 다르다. --- -## 3. LLM이 들어가는 자리 (세 군데뿐) +## 4. LLM이 하는 일 — 이름 문제 셋 -LLM은 **기능성을 판단하지 않는다.** 기능성 유형은 오직 안전나라 응답 필드에서만 나온다. -LLM은 "무엇을 검색할지"와 "이 후보가 같은 제품인지"만 답한다. 규제 데이터를 모델이 -지어내지 못하게 하는 경계다. +`infrastructure/GeminiProductNameResolver.java` (포트: `application/port/ProductNameResolverPort`) -모델은 **Gemini**(`gemini-3.8-flash`)를 쓴다. Interactions API(`/v1beta/interactions`)를 +모델은 **Gemini**(`gemini-3.8-flash`). Interactions API(`POST /v1beta/interactions`)를 `RestClient`로 직접 부른다 — 이 프로젝트의 다른 외부 연동과 같은 방식이라 SDK 의존성이 없다. -| 자리 | 입력 | 출력 | 왜 LLM인가 | +| | 메서드 | 입력 → 출력 | 호출 | |---|---|---|---| -| ① 브랜드 한글 표기 | `Goodal`, `d'Alba`, `Round Lab` | `구달`, `달바`, `라운드랩` | 음차 표기는 규칙으로 못 만든다. 브랜드당 **1회**만 부르고 캐시한다 | -| ② 등록명 후보 생성 | `구달 청귤 비타C 잡티 세럼` | `구달청귤비타씨잡티세럼`, `구달청귤비타C잡티세럼` … | `비타C→비타씨`, `10%→10퍼센트` 같은 표기 변환 | -| ③ 후보 판정 | 제품 정보 + 안전나라 후보 N건 | 몇 번이 같은 제품인가 / 없음 + 신뢰도 | `311 vs 131`은 유사도로는 못 거르고 의미로 걸러야 한다 | +| ① | `koreanBrandAliases` | `Torriden` → `["토리든", …]` 최대 4개 | 브랜드당 1회(캐시) | +| ② | `registrationNameCandidates` | `구달 청귤 비타C 잡티 세럼` → 등록명 후보 최대 6개 | 제품당 1회 | +| ③ | `judge` | 제품 + 등록 후보 20건 → `{index, confidence, reason}` | 조건부 1회 | -①은 브랜드 단위 캐시라 호출이 거의 없고, ②③은 제품당 최대 2회다. 후보가 0건이거나 -정규화만으로 완전 일치하면 ③은 아예 부르지 않는다. +**LLM은 기능성을 판단하지 않는다.** 유형은 오직 안전나라 응답에서만 나온다(6절). 프롬프트 +어디에도 "이게 미백인가"를 묻는 곳이 없다 — 규제 정보를 모델이 지어내지 못하게 하는 경계다. -> 웹 검색은 쓰지 않는다. 법인명은 API 응답(`ENTP_NAME`)이 알려주므로 검색할 이유가 없고, -> 한글 표기를 끝내 모르는 브랜드는 `NOT_MATCHED`로 사람 큐에 남는다 — 모델이 추측한 표기로 -> 조회해서 0건을 받고 "기능성 아님"으로 접는 쪽이 훨씬 위험하다. +`judge`는 **하나만 지목**한다. 순위나 복수 추천이 아니라 "몇 번이 같은 제품인가, 없으면 -1". ---- - -## 4. 기능성 유형은 어디서 나오는가 +### ① 브랜드 한글 표기 -`COSMETIC_TARGET_FLAG_NAME`(제10조 제1항 제○호)은 **효능이 아니라 보고 근거 구분**이다. -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 +음차 그대로 Goodal → 구달, d'Alba → 달바, Torriden → 토리든 +국내 유통명 Beauty of Joseon → 조선미녀 (뷰티오브조선 0건) +음차 갈림 numbuzin → 넘버즈인 / 넘버스인 +숫자·영문 혼합 SKIN1004 → 스킨1004 / 스킨천사 ``` -그래서 유형은 이 순서로 도출한다. +### ② 등록명 후보 -1. `EE_NAME` 텍스트 → 미백 / 주름개선 / 여드름 / 피부장벽 / 튼살 / 자외선 -2. `EE_NAME`이 비어 있고 `SPF`·`PA`가 있으면 → **자외선 차단** (선크림 보고 건의 전형) -3. `EE_NAME`이 `염모`·`탈색`·`제모`면 → 우리 6분류 밖 ⇒ **자동 확정하지 않고 사람에게** -4. 둘 다 없으면 → **도출 불가 ⇒ 사람에게** (제품은 찾았지만 유형을 못 정한 경우) +규칙 2개 + LLM 최대 6개, 합쳐서 최대 8개(`max-search-terms`)로 각각 조회한다. -`CANCEL_APPROVAL_YN='Y'`(취하)인 행은 후보에서 제외한다. +- 규칙: `브랜드한글명+제품명` 정규화 / 제품명만 정규화 +- LLM: 표기 변환(`비타C→비타씨`), 대괄호·용량 제거, 갈리는 표기는 각각, **제품명이 길면 + 앞부분만 자른 형태도 하나** — 부분 일치라 `구달청귤비타`만으로도 걸린다 +- 제품명의 숫자는 절대 바꾸지 않게 한다 + +### ③ 후보 판정 + +**임계값을 통과한 것만 넘기지 않는다.** 유사도는 표기가 크게 다른 제품을 통째로 떨어뜨린다 — +`Beauty of Joseon 릴리프 선`은 정답인 `조선미녀맑은쌀선크림`과 **0.36으로 꼴찌**였다. +브랜드 등록 목록을 점수순으로 넉넉히(기본 20건) 넘기고 고르는 일을 판정에 맡긴다. --- -## 5. 판정 등급과 폴백 +## 5. 한글 이름 저장 → 판정까지 + +`POST /admin/products/{id}/workflow/functional-screening` → `FunctionalScreeningService.decide()` ``` - 한글 이름 저장 ────┐ ┌─────────────────────────────────────────┐ - (기능성 확인 탭) ├─┤ 1. 직접 검색 (정규화된 브랜드+제품명) │ - │ │ 2. LLM 등록명 후보로 재검색 │ - INGREDIENTS_ADDED ─┘ │ 3. 브랜드 전수 조회 후 그 안에서 매칭 │ - │ 4. 취하 건 제외 · 점수 정렬 · 상위 5건 │ - └───────────────┬─────────────────────────┘ - │ - ┌────────────────────────────┼────────────────────────────┐ - ▼ ▼ ▼ - AUTO_CONFIRMED NEEDS_REVIEW NOT_MATCHED - 자동 확정 후보는 있는데 애매 후보 0건 - → READY_FOR_INCIAPI → 같은 화면에 후보 제시 → 같은 화면, 사유 표시 - → 큐로 복귀, 다음 건 → 사람이 고름 → 사람이 직접 입력 - (+ 브랜드 0건이면 - AUTO_NONE 후보) +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_CONFIRMED 조건 — 전부 만족해야 한다.** +### 결말 + +| 조건 | 결과 | +|---|---| +| 점수 ≥ `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-threshold`(기본 0.95), 또는 LLM 판정이 `HIGH` -- **숫자 토큰 집합이 완전히 일치** (`311 ≠ 131`, `77 ≠ 70`) ← 실측에서 나온 하드 룰 -- `ENTP_NAME`이 같은 브랜드의 다른 등록 건과 일치하거나, 등록명이 브랜드 토큰으로 시작 -- 4절 규칙으로 **기능성 유형이 도출됨** (도출 불가면 확정하지 않는다) +### AUTO_NONE ("기능성 아님" 자동 확정) — 기본 꺼짐 -**AUTO_NONE("기능성 아님" 자동 확정)** — 기본 **꺼져 있다**(`auto-conclude-none: false`). -켤 경우에도 아래를 모두 만족할 때만 확정한다. +켤 경우에도 아래를 모두 만족할 때만: -- 브랜드 전수 조회가 **한글 표기로** 정상 종료했고 그 브랜드의 등록 건이 **0건** - (영문 브랜드명으로 조회하면 등록명이 전부 한글이라 무조건 0건이 나온다 — 이 0건은 근거가 아니다) -- 카테고리가 `sunscreens`가 아님 (선크림은 법적으로 기능성이어야 한다 — 미검색은 실패로 본다) +- 브랜드 전수 조회를 **한글 표기로** 했고 등록이 **0건** + (영문 브랜드명으로는 무조건 0건이라 근거가 되지 못한다) +- 카테고리가 `sunscreens`가 아님 — 선크림은 법적으로 기능성이어야 하므로 미검색은 실패로 본다 - 제품명에 `선/자차/톤업/미백/주름/브라이트닝/UV/SPF` 같은 기능성 암시 토큰이 없음 -**실패는 조용히 넘어가지 않는다.** API 오류·타임아웃·LLM 오류는 `FAILED`로 남기고 사람 -큐에 그대로 둔다. 판정 못 한 것을 "기능성 아님"으로 접지 않는다. +--- + +## 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에는 효능효과가 없어 유형을 도출할 수 없다 — 심사에서만 발견되면 "기능성이긴 하다" +까지만 알 수 있고 유형은 사람이 고른다. --- -## 6. 데이터 · 상태 +## 7. 데이터 · 상태 -`ProductStatus`는 **건드리지 않는다.** 지금 설계대로 status는 "누가 움직일 차례인가"만 -말하고, 자동 판정의 내막은 새 컬렉션 `functional_screenings`(제품당 1건)가 담는다. +`ProductStatus`는 건드리지 않는다. status는 "누가 움직일 차례인가"만 말하고, 판정의 내막은 +`functional_screenings`(제품당 1건)가 담는다. ``` functional_screenings _id = product_id outcome AUTO_CONFIRMED | AUTO_NONE | NEEDS_REVIEW | NOT_MATCHED | FAILED - claims [WHITENING, ...] ← 안전나라에서 도출된 유형 - candidates[] { itemName, entpName, source, score, claims, reportDate, eeName, spf, pa } - selectedIndex 확정/제안된 후보 (없으면 -1) + 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 사유 포함) - brandRegistryCount 브랜드 전수 조회 건수 (0이면 음성 근거) - decidedBy AUTO | ADMIN - engineVersion 판정 규칙 버전 — 규칙을 바꾸면 재판정 대상을 고를 수 있다 - screenedAt + brand_registry_count 브랜드 조회 건수 (0이면 음성 근거) + decided_by AUTO | ADMIN + engine_version 판정 규칙 버전 — 규칙을 바꾸면 재판정 대상을 고를 수 있다 + screened_at ``` -`decidedBy`와 `engineVersion`을 남기는 이유: 규제 정보라서 "누가·어느 규칙으로 이렇게 -정했는가"를 나중에 되짚을 수 있어야 하고, 규칙을 고쳤을 때 재판정 범위를 고를 수 있어야 한다. +제품의 한글 이름은 Mongo `products.name_kr` 이다(예전 `name_ko`에서 변경). 같은 컬렉션에 +쓰는 seoulection-server-V2 `product-admin/AdminProductDocument`도 함께 바꿔야 하고, 기존 +문서가 있으면 배포 전에 `$rename`을 돌려야 한다. --- -## 7. 화면 +## 8. 화면 -기능성 확인 탭은 위에서 아래로 이렇게 흐른다. +기능성 확인 탭은 위에서 아래로 흐른다. -1. **한글 이름 + [저장하고 자동 조회]** — 이게 자동화의 입구다. 누르면 저장·조회·기록이 - 한 번에 끝나고, 확정되면 큐로 돌아간다. -2. **자동 조회 결과 카드** — 확정하지 못했을 때만 의미가 있다. - - 판정 배지와 사유 한 줄 (`제품명 숫자가 다릅니다(131) — 다른 제품일 수 있습니다`) - - 후보 표: 등록명 · 업체명 · 등록일 · 효능효과 · 도출 유형 · 점수 — 각 행에 [이 후보로 채우기] - - 브랜드 전수 건수 (`이 브랜드의 안전나라 기능성 등록: 37건`) -3. **직접 입력 폼** — 지금 쓰는 그 폼 그대로. 자동이 못 한 것만 사람이 채운다. +1. **한글 이름 + [저장하고 자동 조회]** — 자동화의 입구 +2. **자동 조회 결과 카드** + - 판정 배지와 사유 한 줄 + - 후보 표: 등록명 · 업체 · 등록일 · 효능효과 · 도출 유형 · 점수, 행마다 [이 후보로 채우기] + - 판정이 고른 행에는 **`AI 지목` 배지 + 행 강조** + - "브랜드 등록 N건" +3. **직접 입력 폼** — 자동이 못 한 것만 사람이 채운다 -"왜 자동으로 안 됐는가"를 함께 보여 주는 게 중요하다. 사유가 없으면 어드민이 방금 기계가 -한 검색을 처음부터 다시 한다. +**라디오는 미리 선택하지 않는다.** 정확히는 유형이 나온 경우만 `기능성 확인`을 찍어 두고, +**"기능성 아님"은 절대 미리 고르지 않는다** — 확인 없이 저장만 눌러도 식약처 기능성이 아니라는 +사실이 기록되는 게 이 폼에서 가장 비싼 실수이고, "자동이 못 찾은 것"과 "실제로 기능성이 아닌 +것"은 겉보기가 같다. -**라디오는 절대 미리 선택하지 않는다.** 지금 폼이 기본값을 두지 않는 이유(확인 없이 저장 -버튼만 눌러도 "기능성 아님"이 사실로 기록되는 걸 막는 것)가 자동화에서도 그대로 유효하다. -자동이 채우는 건 유형 체크박스까지고, 최종 라디오는 사람이 누른다. +외부 호출 중에는 버튼을 잠그고 스피너·진행 띠를 띄운다(`data-busy`). 잠그는 게 핵심이다 — +응답이 늦으면 어드민이 한 번 더 누르고, 서버는 같은 외부 API를 두 번 부른다. --- -## 8. 설정 +## 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 인코딩 키 - report-url: https://apis.data.go.kr/1471000/FtnltCosmRptPrdlstInfoService/getRptPrdlstInq - exam-url: https://apis.data.go.kr/1471057/FtnltCosmSrngPrdlstInfoService04/getSrngPrdlstInq + service-key: ${MFDS_SERVICE_KEY:} # data.go.kr 인코딩 키 그대로. 심사·보고 공용 page-size: 500 - max-brand-pages: 4 # 브랜드 전수 상한 (2000건) - auto-threshold: 0.95 - candidate-threshold: 0.60 - apply-decisions: true # 자동 확정을 상태 전진으로 옮긴다(끄면 그림자 모드) - auto-conclude-none: false # "기능성 아님" 자동 확정 (기본 꺼짐) + max-brand-pages: 4 llm: - enabled: false # 켜면 ①②③ 자리에 Gemini가 들어간다 - model: gemini-3.8-flash + enabled: ${FUNCTIONAL_SCREENING_LLM:false} + model: ${GEMINI_MODEL:gemini-3.8-flash} api-key: ${GEMINI_API_KEY:} ``` -`MFDS_SERVICE_KEY`(data.go.kr)와, LLM을 켤 경우 `GEMINI_API_KEY`(Google AI Studio)가 필요하다. -둘 다 `seoulection-server-V2/.env`에 넣고 compose가 어드민 컨테이너에 환경변수로 꽂아 준다. +키는 `seoulection-server-V2/.env`에 넣고 compose가 어드민 컨테이너에 환경변수로 꽂아 준다 +(어드민 저장소에는 `.env`가 없다). -`llm.enabled=false`면 규칙 기반 대체 구현이 그 자리를 메운다(정규화 변형만 생성, 후보 -판정은 점수로만). 즉 **LLM 없이도 동작하고**, 켜면 회수율이 올라간다. +`llm.enabled=false`면 `HeuristicProductNameResolver`가 대신 뜬다 — 규칙 기반 표기 변환만 하고 +**판정은 하지 않아** 애매한 건 전부 사람 큐로 간다. 즉 **LLM 없이도 동작하고**, 켜면 회수율이 +올라간다. -## 9. 지금 상태와 남은 것 +### 함정 두 가지 -**들어간 것** +- 서비스 키는 이미 URL 인코딩된 상태로 발급된다. `RestClient.uri(String)`에 넣으면 템플릿으로 + 보고 한 번 더 인코딩해 `%2F`가 `%252F`가 되고 403 "등록되지 않은 서비스키"가 난다. + `URI.create()`로 넘겨야 한다. +- 키가 비면 조회가 **빈 목록이 아니라 예외**를 던진다. 조용히 0건이 되면 "기능성 아님"으로 + 기록될 수 있기 때문이다. -- 안전나라 심사·보고 조회, 브랜드 전수 조회, 취하 건 제외 -- 유사도 + 숫자 토큰 + 업체명 + 기능성 도출 규칙에 의한 자동 확정 판정 -- 한글 이름 저장이 트리거인 자동 조회, 후보 표와 [이 후보로 채우기] -- LLM 자리(브랜드 표기·등록명 후보·후보 판정)와 규칙 기반 대체 구현 — 설정 한 줄로 갈린다 -- 판정 기록(`functional_screenings`)과 `decidedBy`·`engineVersion` +--- -**아직 안 한 것 / 켤 때 판단이 필요한 것** +## 11. 남은 것 -1. **LLM 켜기** (`llm.enabled=true` + `GEMINI_API_KEY`) — 회수율이 오르는 대신 제품당 최대 - 2회 호출이 붙는다(브랜드 표기는 브랜드당 1회 캐시). 먼저 규칙만으로 몇 %가 자동 확정되는지 - 본 뒤에 켜는 편이 낫다. -2. **AUTO_NONE 켜기** (`auto-conclude-none=true`) — 브랜드 0건 케이스의 정확도를 실제 - 데이터로 확인한 뒤에만. 지금은 그 케이스도 사람 큐로 간다. +1. **AUTO_NONE 켜기** — 브랜드 0건 케이스의 정확도를 실제 데이터로 확인한 뒤에만 +2. **apply-decisions 켜기** — 자동 확정과 사람 판단의 일치율을 본 뒤에 3. **밀린 제품 일괄 조회** — `POST /admin/products/functional-screening`이 큐를 훑는다 - (기본 50건). 목록 화면에 버튼은 아직 안 붙였다. -4. **재판정** — 규칙을 고치면 `engineVersion`이 다른 판정을 골라 다시 돌리는 일이 필요해진다. + (기본 50건). 목록 화면에 버튼은 아직 없다 +4. **재판정** — 규칙을 고치면 `engine_version`이 다른 판정을 골라 다시 돌리는 일이 필요해진다 +5. **전수 적재 검토** — 보고 391요청 + 심사 42요청 ≈ 4분이면 19만 건을 로컬에 둘 수 있다. + 그러면 업체명 조회가 가능해지고, 자동 조회가 18~22초에서 수십 ms로 줄고, 쿼터 걱정이 + 사라진다. `MfdsCatalogPort` 구현만 갈아끼우면 된다 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 index 943afa8..df81793 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningProperties.java @@ -19,7 +19,14 @@ public class FunctionalScreeningProperties { */ private boolean applyDecisions = false; - /** 이 점수 이상이면 LLM 판정 없이도 확정 후보가 된다(숫자·업체·유형 규칙은 그대로 통과해야 한다). */ + /** + * 자동 확정에 필요한 점수. 이름이 이 정도로 같으면 판정을 부르지 않고 확정 후보로 본다. + * + *

다만 점수 하나로 확정되지는 않는다 — 숫자 토큰 일치, 업체명 연결, 기능성 유형 도출을 + * 모두 통과해야 한다({@code MfdsCandidate.confirmable}). 실측에서 "닥터디퍼런트 311 + * 모이스처라이저"가 등록명 "닥터디퍼런트131모이스처라이저"와 0.94 였는데, 그건 숫자 + * 규칙에서 걸린다. + */ private double autoThreshold = 0.95; /** 이 점수 미만은 후보로도 남기지 않는다. 표를 후보로 채워 놓으면 검수가 더 느려진다. */ 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 index ed9d267..de5a421 100644 --- a/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java +++ b/src/main/java/com/seoulection/admin/product/functional/application/FunctionalScreeningService.java @@ -220,6 +220,7 @@ private FunctionalScreening decide(ScreeningTarget target) { .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); @@ -255,13 +256,16 @@ private FunctionalScreening decide(ScreeningTarget target) { .limit(Math.max(properties.getMaxCandidates() - 1, 0)) .forEach(withChosen::add); - if (verdict.high() && chosen.numericMatch() && chosen.brandMatch() && chosen.claims().autoConfirmable()) { - return confirmed(target, withChosen, 0, verdict.reason(), brandCount); - } + // ⚠️ 판정이 골랐다고 확정하지 않는다. 이름이 완전히 같은 경우(위 분기)를 빼면 확정은 + // 사람 몫이다 — 모델이 고른 근거가 아무리 그럴듯해도 등록명이 다르다는 건 다른 제품일 + // 수 있다는 뜻이고, 기능성은 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 것보다 크다. + // 대신 고른 것을 맨 앞에 세우고 유형까지 채워 둬서, 맞으면 저장만 누르면 되게 한다. 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(), - block.isBlank() ? "판정 신뢰도가 낮아 확인이 필요합니다: " + verdict.reason() : block, + withChosen, 0, verdict.confidence(), reason, brandCount, FunctionalScreening.DECIDED_BY_AUTO, FunctionalScreening.ENGINE_VERSION, Instant.now()); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 65b6be7..9503de9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,9 +25,13 @@ admin: # 자동 판정이 상태까지 바로 옮길지. 기본은 꺼짐 — 조회는 근거를 모아 폼을 채워 두고, # 확정은 어드민이 저장을 눌러 한다. 일치율을 충분히 본 뒤에 켤 스위치다. apply-decisions: ${FUNCTIONAL_SCREENING_APPLY:false} - auto-threshold: 0.95 # 이 점수 이상이면 LLM 판정 없이도 확정 후보 + 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 diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index 16f8f76..1969c78 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -1160,3 +1160,23 @@ td a:hover { text-decoration: underline; } .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/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html index 8b5b23c..e0e5294 100644 --- a/src/main/resources/templates/product-workflow.html +++ b/src/main/resources/templates/product-workflow.html @@ -141,6 +141,11 @@

From 721a62b6b33399e9260755c9f24e9a819c6ca8d2 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 8 Sep 2026 17:44:12 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix(admin):=20=EC=84=B1=EB=B6=84=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=EB=A5=BC=20"=EC=97=86=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=EB=A7=8C"=20=EB=84=A3=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전에는 성분이 하나라도 있으면 통째로 건너뛰었다. 그래서 시드 목록에 성분을 추가해도 영영 들어가지 않는다 — 코드에는 있는데 DB 에는 없어서 "왜 없지"를 찾는 데 시간을 쓴다. 항상 upsert 로 바꾸지 않는 이유: save() 는 별칭·효능을 지우고 다시 넣어서, 어드민이 화면에서 고친 값을 재기동이 되돌린다. 시드의 목적은 기본 성분을 깔아 두는 것이지 코드 값을 강제하는 것이 아니다. 🔴 saveIfAbsent 는 id 와 inci_name 을 둘 다 본다. id 로만 보면 어드민이 같은 성분을 다른 id 로 만들어 둔 경우 삽입이 uq_ingredient_inci_name(대소문자 무시) 위반으로 터지고, 시드가 ApplicationRunner 라 그대로 기동 실패가 된다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT --- .../config/IngredientSeedConfiguration.java | 7 ++++-- .../IngredientPostgresRepository.java | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) 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,

+
+ + + AI 지목 +