Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d6d58a2
feat(notification): 수동 푸시 관리자 화면 추가
Sehi55 Sep 7, 2026
ddade0f
fix(admin): 레거시 성분 시드로 인한 기동 실패 방지
Sehi55 Sep 7, 2026
54cc470
fix(admin): 성분 시드를 항상 실행
Sehi55 Sep 7, 2026
4b9e02d
feat(admin): 제품 성분 특성을 임의 키로 확장하고 데이터 접근을 포트로 분리
Sehi55 Sep 8, 2026
ef0656b
feat(admin): 제품 검수를 4단계 마법사로 재구성
Sehi55 Sep 8, 2026
d84e556
feat(admin): 기기 토큰 조회·해제 화면
Sehi55 Sep 8, 2026
33f48e7
test(admin): API 어댑터 응답 매핑 계약 테스트 + 알림 서비스 계층
Sehi55 Sep 8, 2026
44fd2d2
refactor(admin): 검수 화면을 기존 2탭 구조로 되돌림
Sehi55 Sep 8, 2026
796d43b
feat(admin): 신규 등록에서 한글 이름·전성분을 미리 받는다
Sehi55 Sep 8, 2026
719a194
fix(admin): Anthropic Java SDK 의존성 누락으로 앱이 기동하지 않던 문제
Sehi55 Sep 8, 2026
be58d6d
feat(admin): 설문 문항 근거 관리
Sehi55 Sep 8, 2026
7b2dbd9
fix(admin): 문항 추가 실패 시 500 + 근거 UI 를 페이지 관례에 맞춤
Sehi55 Sep 8, 2026
c8e849d
refactor(admin): 설문 근거를 한 줄 토글로
Sehi55 Sep 8, 2026
bc213f1
feat(admin): 기능성 자동 판정이 브랜드 등록 목록에서 후보를 찾게 한다
Sehi55 Sep 8, 2026
9df5dde
feat(admin): 기능성 자동 확정 조건을 좁히고 AI 지목을 화면에 드러냄
Sehi55 Sep 8, 2026
721a62b
fix(admin): 성분 시드를 "없는 것만" 넣도록
Sehi55 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ repositories {
}

dependencies {
// 기능성 스크리닝의 이름 해석(브랜드 한글 표기·등록명 후보·동일 제품 판정)은 Gemini를 쓴다.
// SDK 의존성 없이 RestClient로 REST를 직접 부른다 — 이 프로젝트의 다른 외부 연동
// (MFDS, V2 product-service, 알림)과 같은 방식이고, 새 의존성이 필요 없다.

implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-validation")
Expand All @@ -27,6 +31,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.postgresql:postgresql")


developmentOnly("org.springframework.boot:spring-boot-devtools")

testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
Expand Down
345 changes: 345 additions & 0 deletions docs/functional-screening.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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 클라이언트 기본값.
*
* <p>타임아웃을 어댑터가 아니라 여기서 거는 이유: 어댑터가 {@code requestFactory()} 를 부르면
* 테스트가 붙여 둔 {@code MockRestServiceServer} 의 팩토리를 덮어써서, 흉내 내려던 요청이
* 진짜로 나가 버린다(2026-09-08 실측). 설정은 설정에 두고 어댑터는 baseUrl 만 정한다.
*
* <p>🔴 타임아웃을 빼지 말 것. 기본값은 무한 대기라, 호출 대상이 멈추면 어드민 화면도 함께 멈춘다.
*/
@Configuration
public class AdminHttpClientConfig {

@Bean
public RestClient.Builder serviceRestClientBuilder() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
return RestClient.builder().requestFactory(factory);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand All @@ -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()));
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ public void deletePropertyDefinition(String key) {
jdbc.update("delete from property_definition where property_key = ?", key);
}

/**
* 시드 전용 — <b>없을 때만</b> 넣고, 이미 있으면 아무것도 하지 않는다.
*
* <p>{@link #save} 는 upsert 라 어드민이 화면에서 고친 값을 재기동이 되돌린다.
* 시드의 목적은 "기본 성분을 깔아 두는 것"이지 "코드 값을 강제하는 것"이 아니다.
*
* <p>🔴 id 와 inci_name 을 <b>둘 다</b> 본다. id 로만 보면, 어드민이 같은 성분을 다른 id 로
* 만들어 둔 경우 삽입이 {@code uq_ingredient_inci_name}(대소문자 무시) 위반으로 터지고
* <b>앱이 기동하지 않는다</b> — 시드는 ApplicationRunner 라 실패가 곧 기동 실패다.
*
* @return 실제로 넣었으면 true
*/
@Transactional
public boolean saveIfAbsent(String id, String inci, String ko, String family,
List<String> aliases, Map<String, String> effects,
Map<String, String> 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<String> aliases, Map<String, String> effects,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.seoulection.admin.notification.application;

import java.util.List;

/**
* 기기 등록 접근 <b>포트</b>.
*
* <p>{@code ProductIngredientPort} 와 같은 이유로 인터페이스다 — 지금은 notification-service 의
* 어드민 API 를 부르지만, 저장소가 옮겨 가거나 서비스가 분리돼도 화면은 이 계약만 본다.
*/
public interface NotificationDevicePort {

List<DeviceView> activeOf(Long userId);

/** 지우지 않고 해제한다 — 서비스 쪽이 revoked_at 을 찍는다. */
void revoke(Long registrationId);

/** 토큰 전문은 받지 않는다. 화면은 기기를 구분하기만 하면 된다. */
record DeviceView(Long id, String platform, String tokenPreview) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.seoulection.admin.notification.application;

import org.springframework.stereotype.Service;

import java.util.List;

/**
* 기기 등록 유스케이스.
*
* <p>지금은 포트로 넘기기만 한다. 그래도 두는 이유는 <b>규칙을 놓을 자리</b>가 필요해서다 —
* 사용자 ID 검증, "이미 해제된 기기" 판단, 해제 사유 기록 같은 것이 생기면 화면도 어댑터도
* 아닌 여기가 그 자리다. {@code ProductService}·{@code SurveyService} 와 같은 층이다.
*
* <p>컨트롤러가 포트를 직접 잡으면 그 규칙이 화면으로 새거나 어댑터마다 중복된다.
*/
@Service
public class NotificationDeviceService {

private final NotificationDevicePort devicePort;

public NotificationDeviceService(NotificationDevicePort devicePort) {
this.devicePort = devicePort;
}

/** 사용자 ID 가 없으면 조회하지 않는다 — 전체 기기 목록은 어드민이 볼 이유가 없고 양도 많다. */
public List<NotificationDevicePort.DeviceView> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.seoulection.admin.notification.infrastructure;

import com.seoulection.admin.notification.application.NotificationDevicePort;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.util.List;

/**
* notification-service 의 어드민 API 를 부른다.
*
* <p>🔴 서비스 키가 비어 있으면 403 이 나는데, 그 실패는 기동이 아니라 화면을 눌렀을 때
* 드러난다. {@code admin.notification-service.service-key} 를 반드시 함께 넣을 것.
*/
@Component
public class ApiNotificationDeviceAdapter implements NotificationDevicePort {

private final RestClient client;
private final String serviceKey;

public ApiNotificationDeviceAdapter(
RestClient.Builder builder,
@Value("${admin.notification-service.base-url:http://notification-service:8080}") String baseUrl,
@Value("${admin.notification-service.service-key:}") String serviceKey) {
this.serviceKey = serviceKey;
this.client = builder.baseUrl(baseUrl).build();
}

@Override
public List<DeviceView> activeOf(Long userId) {
List<DeviceView> 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<List<DeviceView>>() {});
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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.seoulection.admin.notification.presentation;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
* 수동 푸시 발송 화면의 진입점.
*
* <p>아직 notification-service와 연결하지 않았으므로 이 컨트롤러는 데이터를 만들거나 발송하지 않는다.
* 다음 단계에서 이 패키지에 notification-service HTTP 클라이언트를 붙이면, 템플릿의 대상 수와 이력
* 영역만 실제 응답으로 교체하면 된다.</p>
*/
@Controller
public class NotificationAdminController {

@GetMapping("/admin/notifications")
public String page() {
return "notifications";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.seoulection.admin.notification.presentation;

import com.seoulection.admin.notification.application.NotificationDeviceService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;


/**
* 기기 토큰 관리 화면.
*
* <p>문의 대응용이다 — "푸시가 안 온다", "지운 기기로 계속 온다" 는 죽은 토큰이 원인인 경우가
* 많은데, 그것을 확인하고 끊을 자리가 없었다.
*
* <p>사용자 ID 로만 조회한다. 토큰으로 역조회하지 않는 이유: 어드민이 토큰을 알 방법이 없고,
* 토큰을 입력받는 화면을 두면 그 값이 로그·브라우저 기록에 남는다.
*/
@Controller
public class NotificationDeviceController {

private final NotificationDeviceService service;

public NotificationDeviceController(NotificationDeviceService service) {
this.service = service;
}

@GetMapping("/admin/notifications/devices")
public String page(@RequestParam(required = false) Long userId, Model model) {
model.addAttribute("userId", userId);
model.addAttribute("devices", service.activeDevices(userId));
model.addAttribute("searched", userId != null);
return "notification-devices";
}

@PostMapping("/admin/notifications/devices/{registrationId}/revoke")
public String revoke(@PathVariable Long registrationId, @RequestParam Long userId,
RedirectAttributes redirectAttributes) {
service.revoke(registrationId);
redirectAttributes.addFlashAttribute("successMessage",
"기기 등록을 해제했습니다. 이 기기로는 더 이상 발송되지 않습니다.");
return "redirect:/admin/notifications/devices?userId=" + userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.seoulection.admin.product.application.dto;

import java.math.BigDecimal;

/**
* 제품 안에서의 성분 특성 한 줄.
*
* <p>왜 컬럼이 아니라 이런 목록인가: 담아야 할 축이 순도만이 아니다. "이 제품의 히알루론산은
* 저분자", "아줄렌 순도 99%" 처럼 성분·제품마다 다른 축이 계속 생긴다. 컬럼으로 두면 축이
* 하나 늘 때마다 마이그레이션이 필요하다.
*
* <p>수치는 {@code valueMin}/{@code valueMax}, 범주형은 {@code valueText} 를 쓴다.
* 어느 쪽을 쓸지는 {@code property_definition.value_type} 이 정한다.
*/
public record ProductIngredientProperty(String propertyKey, String displayNameKo, String valueType,
String valueText, BigDecimal valueMin, BigDecimal valueMax,
String valueUnit, String notes) {

/** 폼에서 올라온 값. 표시용 이름과 타입은 정의에서 오는 것이라 여기서는 모른다. */
public ProductIngredientProperty(String propertyKey, String valueText, BigDecimal valueMin,
BigDecimal valueMax, String valueUnit, String notes) {
this(propertyKey, null, null, valueText, valueMin, valueMax, valueUnit, notes);
}

/**
* 숫자형인가. 화면이 최소·최대 두 칸을 그릴지 텍스트 한 칸을 그릴지 이걸로 정한다.
*
* <p>저장된 특성에도 타입이 붙어 있어야 하는 이유: 화면에서 정의 목록을 다시 뒤져
* 짝을 찾는 식은 표현식이 길어지고 실제로 한 번 깨진 적이 있다. 조회 쿼리가 정의를
* 이미 조인하고 있으니 거기서 같이 가져온다.
*/
public boolean numeric() {
if (valueType == null) {
return valueMin != null || valueMax != null;
}
String upper = valueType.toUpperCase();
return upper.startsWith("NUMERIC") || upper.startsWith("NUMBER");
}

/** 화면에 한 줄로 보여줄 값. 범위면 "1~5 %", 단일값이면 "99 %", 범주형이면 그 문자열. */
public String display() {
if (valueText != null && !valueText.isBlank()) return valueText;
if (valueMin == null && valueMax == null) return "";
String unit = valueUnit == null ? "" : " " + valueUnit;
if (valueMin != null && valueMax != null && valueMin.compareTo(valueMax) != 0) {
return valueMin.stripTrailingZeros().toPlainString() + "~"
+ valueMax.stripTrailingZeros().toPlainString() + unit;
}
BigDecimal single = valueMin != null ? valueMin : valueMax;
return single.stripTrailingZeros().toPlainString() + unit;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.seoulection.admin.product.application.dto;

import java.math.BigDecimal;
import java.util.List;

/**
* 화면에 뿌릴 제품 성분 한 줄.
*
* <p>{@code matchedInciName} 이 null 이면 성분 사전에서 못 찾은 것이다 — 화면이 그걸 표시해
* 어드민이 사전에 추가하도록 유도한다. 못 찾는 것은 오류가 아니라 정상적인 상태다.
*
* <p>순도가 별도 필드가 아니라 {@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<ProductIngredientProperty> properties) {

/** 성분 사전과 연결됐는가. 화면의 매칭 배지가 이걸 본다. */
public boolean matched() {
return ingredientId != null && !ingredientId.isBlank();
}

/** 표시용 이름 — 사전에 있으면 한글명, 없으면 원문. */
public String displayName() {
if (matchedNameKo != null && !matchedNameKo.isBlank()) return matchedNameKo;
return rawName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public record ProductResult(
String id,
String asin,
String name,
String nameKo,
String brand,
String category,
String description,
Expand All @@ -35,6 +36,7 @@ public static ProductResult from(Product product) {
product.id(),
product.asin(),
product.name(),
product.nameKo(),
product.brand(),
product.category(),
product.description(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.seoulection.admin.product.application.dto;

/**
* 특성 키의 정의. 화면이 "무엇을 입력할 수 있는가"를 이 목록으로 그린다.
*
* <p>{@code valueType} 이 숫자형이면 최소·최대 두 칸을, 그 외에는 텍스트 한 칸을 보여준다.
*
* <p>⚠️ 실제 {@code property_definition.value_type} 값은 {@code NUMBER}/{@code TEXT} 다.
* 예전 판정은 {@code NUMERIC} 으로 시작하는지만 봐서 <b>모든 정의가 텍스트로 렌더링</b>됐다
* (순도·분자량에 최소/최대 대신 자유 입력 칸이 떴다). 두 어휘를 모두 받는다.
*/
public record PropertyDefinitionResult(String propertyKey, String displayNameKo, String valueType,
String valueUnit, String description) {

public boolean numeric() {
if (valueType == null) {
return false;
}
String upper = valueType.toUpperCase();
return upper.startsWith("NUMERIC") || upper.startsWith("NUMBER");
}
}
Loading