diff --git a/build.gradle.kts b/build.gradle.kts index 65478b0..1ac8703 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-mongodb") - // 설문 문항·선택지 마스터만 Postgres다(나머지 관리 대상은 Mongo). 스키마 주인은 api-server이고 + // 설문 문항·선택지와 성분 카탈로그는 Postgres다(나머지 관리 대상은 Mongo). 스키마 주인은 api-server이고 // 여기서는 ddl-auto=none으로 붙는다 — application.yml 주석 참조. implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("org.postgresql:postgresql") diff --git a/src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java b/src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java new file mode 100644 index 0000000..bf1f6f3 --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java @@ -0,0 +1,118 @@ +package com.seoulection.admin.ingredient.application.service; + +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient; +import com.seoulection.admin.ingredient.infrastructure.repository.IngredientPostgresRepository; +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient.EvidenceView; +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient.EfficacyRangeView; +import com.seoulection.admin.ingredient.infrastructure.repository.PropertyDefinitionView; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Arrays; +import java.util.Map; +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient.ConditionView; + +@Service +public class IngredientService { + private final IngredientPostgresRepository repository; + + public IngredientService(IngredientPostgresRepository repository) { this.repository = repository; } + public List getIngredients() { return repository.findAll(); } + public List getPropertyDefinitions() { return repository.findPropertyDefinitions(); } + + public Ingredient getIngredient(String id) { + Ingredient ingredient = repository.findById(id); + if (ingredient == null) throw new IllegalArgumentException("성분을 찾을 수 없습니다: " + id); + return ingredient; + } + + public void create(String inciName, String displayNameKo, String family, + String aliasesText, String effectsText) { + create(inciName, displayNameKo, family, aliasesText, effectsText, "", "", "", ""); + } + + public void create(String inciName, String displayNameKo, String family, + String aliasesText, String effectsText, + String propertiesText, String evidenceText, String efficacyRangesText) { + create(inciName, displayNameKo, family, aliasesText, effectsText, propertiesText, evidenceText, efficacyRangesText, ""); + } + + public void create(String inciName, String displayNameKo, String family, + String aliasesText, String effectsText, + String propertiesText, String evidenceText, String efficacyRangesText, String efficacyConditionsText) { + String id = java.util.UUID.randomUUID().toString(); + repository.save(id, inciName.trim(), displayNameKo.trim(), family.trim(), + split(aliasesText), parseEffects(effectsText), parseMap(propertiesText)); + repository.replaceEvidence(id, parseEvidence(evidenceText)); + repository.replaceRanges(id, parseRanges(efficacyRangesText), parseConditions(efficacyConditionsText)); + } + + public void update(String id, String inciName, String displayNameKo, String family, + String aliasesText, String effectsText) { + update(id, inciName, displayNameKo, family, aliasesText, effectsText, "", "", "", ""); + } + + public void update(String id, String inciName, String displayNameKo, String family, + String aliasesText, String effectsText, + String propertiesText, String evidenceText, String efficacyRangesText) { + update(id, inciName, displayNameKo, family, aliasesText, effectsText, propertiesText, evidenceText, efficacyRangesText, ""); + } + + public void update(String id, String inciName, String displayNameKo, String family, + String aliasesText, String effectsText, + String propertiesText, String evidenceText, String efficacyRangesText, String efficacyConditionsText) { + repository.save(id, inciName.trim(), displayNameKo.trim(), family.trim(), + split(aliasesText), parseEffects(effectsText), parseMap(propertiesText)); + if (evidenceText != null && !evidenceText.isBlank()) repository.replaceEvidence(id, parseEvidence(evidenceText)); + if (efficacyRangesText != null && !efficacyRangesText.isBlank()) repository.replaceRanges(id, parseRanges(efficacyRangesText), parseConditions(efficacyConditionsText)); + } + + private List split(String value) { + if (value == null || value.isBlank()) return List.of(); + return Arrays.stream(value.split("[,\\n]")).map(String::trim).filter(v -> !v.isBlank()).distinct().toList(); + } + + private Map parseEffects(String value) { + if (value == null || value.isBlank()) return Map.of(); + return Arrays.stream(value.split("[,\\n]")) + .map(String::trim).filter(v -> v.contains("=")) + .map(v -> v.split("=", 2)) + .collect(java.util.stream.Collectors.toMap(v -> v[0].trim(), v -> v[1].trim(), (a, b) -> b)); + } + + private Map parseMap(String value) { + return parseEffects(value); + } + + private List parseEvidence(String value) { + if (value == null || value.isBlank()) return List.of(); + return Arrays.stream(value.split("\\n")) + .map(String::trim).filter(v -> !v.isBlank()).map(v -> v.split("\\|", -1)) + .filter(v -> v.length >= 5 && !v[1].isBlank()) + .map(v -> new EvidenceView(null, v[0], v[1], blank(v, 2), Boolean.parseBoolean(blank(v, 3)), + blank(v, 4), blank(v, 5), Boolean.parseBoolean(blank(v, 6)), blank(v, 7), blank(v, 8), blank(v, 9), blank(v, 10))) + .toList(); + } + + private List parseRanges(String value) { + if (value == null || value.isBlank()) return List.of(); + return Arrays.stream(value.split("\\n")) + .map(String::trim).filter(v -> !v.isBlank()).map(v -> v.split("\\|", -1)) + .filter(v -> v.length >= 1 && !v[0].isBlank()) + .map(v -> new EfficacyRangeView(v[0], blank(v, 1), blank(v, 2), blank(v, 3), blank(v, 4), blank(v, 5), blank(v, 6), longValue(v, 7), blank(v, 8))) + .toList(); + } + + private List parseConditions(String value) { + if (value == null || value.isBlank()) return List.of(); + return Arrays.stream(value.split("\\n")) + .map(String::trim).filter(v -> !v.isBlank()).map(v -> v.split("\\|", -1)) + .filter(v -> v.length >= 2 && !v[0].isBlank() && !v[1].isBlank()) + .map(v -> new ConditionView(v[0].trim(), v[1].trim(), blank(v, 5), blank(v, 2), blank(v, 3), blank(v, 4), + blank(v, 6) == null ? "EXACT_VALUE" : blank(v, 6), Boolean.parseBoolean(blank(v, 7)))) + .toList(); + } + + private String blank(String[] values, int index) { return index < values.length && !values[index].isBlank() ? values[index].trim() : null; } + private Long longValue(String[] values, int index) { return blank(values, index) == null ? null : Long.valueOf(values[index].trim()); } +} 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 new file mode 100644 index 0000000..4b473bb --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java @@ -0,0 +1,45 @@ +package com.seoulection.admin.ingredient.infrastructure.config; + +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient; +import com.seoulection.admin.ingredient.infrastructure.repository.IngredientPostgresRepository; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; +import java.util.Map; + +@Configuration +public class IngredientSeedConfiguration { + @Bean + ApplicationRunner seedIngredients(IngredientPostgresRepository repository) { + return args -> { + if (!repository.findAll().isEmpty()) return; + 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")), + i("00000000-0000-0000-0000-000000000103","Panthenol","Panthenol","판테놀","VITAMIN_B5_DERIVATIVE",List.of("D-Panthenol","판테놀"),List.of("SOOTHING_SEARCH"),Map.of("SENSITIVITY_SCORE","CORE","WATER_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), + i("00000000-0000-0000-0000-000000000104","Centella Asiatica","Centella Asiatica","병풀","BOTANICAL_EXTRACT",List.of("Cica","병풀"),List.of("SOOTHING_SEARCH"),Map.of("SENSITIVITY_SCORE","SUPPORT","RED_SPOT_SCORE","SUPPORT"),Map.of()), + i("00000000-0000-0000-0000-000000000105","Retinol","Retinol","레티놀","RETINOID",List.of("Vitamin A","레티놀"),List.of("RETINOID_SEARCH"),Map.of("WRINKLE_SCORE","CORE","ROUGH_SCORE","CORE"),Map.of("SOLUBILITY","OIL_SOLUBLE")), + i("00000000-0000-0000-0000-000000000106","Retinal","Retinal","레티날","RETINOID",List.of("Retinaldehyde","레티날"),List.of("RETINOID_SEARCH"),Map.of("WRINKLE_SCORE","CORE","ROUGH_SCORE","CORE"),Map.of()), + i("00000000-0000-0000-0000-000000000107","Niacinamide","Niacinamide","나이아신아마이드","VITAMIN_B3_DERIVATIVE",List.of("Nicotinamide","나이아신아마이드"),List.of("BRIGHTENING_SEARCH"),Map.of("MELANIN_SCORE","CORE","OILY_INTENSITY_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), + i("00000000-0000-0000-0000-000000000108","Ascorbic Acid","Ascorbic Acid","비타민 C","VITAMIN_C_DERIVATIVE",List.of("Vitamin C","L-Ascorbic Acid"),List.of("BRIGHTENING_SEARCH"),Map.of("MELANIN_SCORE","CORE","ROUGH_SCORE","SUPPORT"),Map.of("STABILITY","OXIDATION_SENSITIVE")), + i("00000000-0000-0000-0000-000000000109","Collagen","Collagen","콜라겐","PROTEIN",List.of("Hydrolyzed Collagen","콜라겐"),List.of("BARRIER_SEARCH"),Map.of("ROUGH_SCORE","SUPPORT"),Map.of()), + i("00000000-0000-0000-0000-000000000110","Madecassoside","Madecassoside","마데카소사이드","TRITERPENOID_DERIVATIVE",List.of("마데카소사이드"),List.of("SOOTHING_SEARCH","BARRIER_SEARCH"),Map.of("SENSITIVITY_SCORE","SUPPORT"),Map.of()), + i("00000000-0000-0000-0000-000000000111","Ceramide NP","Ceramide NP","세라마이드 NP","CERAMIDE",List.of("Ceramide","세라마이드"),List.of("BARRIER_SEARCH"),Map.of("BARRIER_SCORE","CORE","WATER_SCORE","CORE","SENSITIVITY_SCORE","SUPPORT"),Map.of("SOLUBILITY","OIL_DISPERSIBLE")), + i("00000000-0000-0000-0000-000000000112","Salicylic Acid","Salicylic Acid","살리실산","BETA_HYDROXY_ACID",List.of("BHA","살리실산"),List.of("EXFOLIATION_SEARCH"),Map.of("BLACKHEAD_SCORE","CORE","ACNE_SCORE","CORE"),Map.of()), + i("00000000-0000-0000-0000-000000000113","Kojic Acid","Kojic Acid","코직산","PHENOLIC_COMPOUND",List.of("Kojic","코직산"),List.of("BRIGHTENING_SEARCH"),Map.of("MELANIN_SCORE","CORE"),Map.of("STABILITY","OXIDATION_SENSITIVE")), + i("00000000-0000-0000-0000-000000000114","Guaiazulene","Guaiazulene","구아이아줄렌","AZULENE_DERIVATIVE",List.of("Azulene","아줄렌"),List.of("SOOTHING_SEARCH"),Map.of("SENSITIVITY_SCORE","SUPPORT","RED_SPOT_SCORE","SUPPORT"),Map.of("SOLUBILITY","OIL_SOLUBLE")), + 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(), + seed.getFamily(), seed.getAliases(), seed.getEffects(), seed.getProperties())); + }; + } + + private Ingredient i(String id, String name, String inci, String ko, String family, + List aliases, List groups, Map effects, Map properties) { + return new Ingredient(id, inci, ko, family, aliases, effects, properties); + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/document/Ingredient.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/document/Ingredient.java new file mode 100644 index 0000000..1ec6075 --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/document/Ingredient.java @@ -0,0 +1,65 @@ +package com.seoulection.admin.ingredient.infrastructure.document; + +import java.util.List; +import java.util.Map; + +public class Ingredient { + private String id; + private String inciName; + private String displayNameKo; + private String family; + private List aliases; + private Map effects; + private Map properties; + private List evidences; + private List efficacyRanges; + + protected Ingredient() { } + + public Ingredient(String id, String inciName, String displayNameKo, + String family, List aliases, + Map effects, Map properties) { + this(id, inciName, displayNameKo, family, aliases, effects, properties, List.of(), List.of()); + } + + public Ingredient(String id, String inciName, String displayNameKo, + String family, List aliases, + Map effects, Map properties, + List evidences, List efficacyRanges) { + this.id = id; this.inciName = inciName; this.displayNameKo = displayNameKo; this.family = family; this.aliases = aliases; + this.effects = effects; this.properties = properties; + this.evidences = evidences; this.efficacyRanges = efficacyRanges; + } + + public String getId() { return id; } + public String getInciName() { return inciName; } + public String getDisplayNameKo() { return displayNameKo; } + public String getFamily() { return family; } + public List getAliases() { return aliases == null ? List.of() : aliases; } + public Map getEffects() { return effects == null ? Map.of() : effects; } + public Map getProperties() { return properties == null ? Map.of() : properties; } + public List getEvidences() { return evidences == null ? List.of() : evidences; } + public List getEfficacyRanges() { return efficacyRanges == null ? List.of() : efficacyRanges; } + + public record EvidenceView(Long id, String sourceType, String title, String url, boolean humanEvidence, + String evidenceLevel, String targetScore, boolean ingredientSpecific, + String productForm, String studyConcentration, String studyConcentrationUnit, + String summary) { } + + public record EfficacyRangeView(String targetKey, String productType, String concentrationMin, + String concentrationMax, String concentrationUnit, + String onsetConcentration, String irritationConcentration, + Long evidenceId, String notes, String role, List conditions) { + public EfficacyRangeView(String targetKey, String productType, String concentrationMin, + String concentrationMax, String concentrationUnit, + String onsetConcentration, String irritationConcentration, + Long evidenceId, String notes) { + this(targetKey, productType, concentrationMin, concentrationMax, concentrationUnit, + onsetConcentration, irritationConcentration, evidenceId, notes, "SUPPORT", List.of()); + } + } + + public record ConditionView(String targetKey, String parameterKey, String valueText, + String valueMin, String valueMax, String valueUnit, + String conditionMode, boolean interpolationAllowed) { } +} 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 new file mode 100644 index 0000000..dd5b2d7 --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientPostgresRepository.java @@ -0,0 +1,170 @@ +package com.seoulection.admin.ingredient.infrastructure.repository; + +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.math.BigDecimal; +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient.EvidenceView; +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient.EfficacyRangeView; +import com.seoulection.admin.ingredient.infrastructure.document.Ingredient.ConditionView; + +@Repository +public class IngredientPostgresRepository { + private final JdbcTemplate jdbc; + + public IngredientPostgresRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + public List findAll() { + return jdbc.query("select id, inci_name, display_name_ko, family from ingredient order by id", + (rs, n) -> toIngredient(rs.getString("id"), rs.getString("inci_name"), rs.getString("display_name_ko"), rs.getString("family"))); + } + + public Ingredient findById(String id) { + return jdbc.query("select id, inci_name, display_name_ko, family from ingredient where id = ?", ps -> ps.setString(1, id), + rs -> rs.next() ? toIngredient(rs.getString("id"), rs.getString("inci_name"), rs.getString("display_name_ko"), rs.getString("family")) : null); + } + + public List findPropertyDefinitions() { + return jdbc.query("select property_key, display_name_ko, value_type, value_unit, description from property_definition order by property_key", + (rs, n) -> new PropertyDefinitionView(rs.getString(1), rs.getString(2), rs.getString(3), rs.getString(4), rs.getString(5))); + } + + public void savePropertyDefinition(String key, String displayNameKo, String valueType, String valueUnit, String description) { + jdbc.update("insert into property_definition(property_key, display_name_ko, value_type, value_unit, description) values (?, ?, ?, ?, ?) " + + "on conflict (property_key) do update set display_name_ko=excluded.display_name_ko, value_type=excluded.value_type, value_unit=excluded.value_unit, description=excluded.description", + key.trim().toUpperCase(), displayNameKo.trim(), valueType.trim().toUpperCase(), blank(valueUnit), blank(description)); + } + + public void deletePropertyDefinition(String key) { + if (jdbc.queryForObject("select count(*) from ingredient_property where property_key = ?", Long.class, key) > 0 + || jdbc.queryForObject("select count(*) from product_ingredient_property where property_key = ?", Long.class, key) > 0) { + throw new IllegalArgumentException("사용 중인 특성은 삭제할 수 없습니다. 값을 먼저 정리해 주세요."); + } + jdbc.update("delete from property_definition where property_key = ?", key); + } + + @Transactional + public void save(String id, String inci, String ko, String family, + List aliases, Map effects, + Map properties) { + String actualId = id == null || id.isBlank() ? UUID.randomUUID().toString() : id; + jdbc.update("insert into ingredient (id, inci_name, display_name_ko, family) values (?, ?, ?, ?) " + + "on conflict (id) do update set inci_name=excluded.inci_name, display_name_ko=excluded.display_name_ko, family=excluded.family", + actualId, inci, ko, family); + jdbc.update("delete from ingredient_alias where ingredient_id = ?", actualId); + aliases.forEach(alias -> jdbc.update("insert into ingredient_alias (ingredient_id, alias, alias_type) values (?, ?, 'SYNONYM') on conflict do nothing", actualId, alias)); + jdbc.update("delete from ingredient_effect where ingredient_id = ?", actualId); + effects.forEach((target, role) -> jdbc.update("insert into ingredient_effect (ingredient_id, target_key, role) values (?, ?, ?) on conflict (ingredient_id, target_key) do update set role=excluded.role", actualId, target, role)); + // 현재 관리자 폼은 특성을 편집하지 않으므로 수정 시 기존 특성을 보존한다. + if (!properties.isEmpty()) { + properties.keySet().forEach(key -> { + if (jdbc.queryForObject("select count(*) from property_definition where property_key = ?", Long.class, key) == 0) { + throw new IllegalArgumentException("등록되지 않은 특성 키입니다: " + key); + } + }); + jdbc.update("delete from ingredient_property where ingredient_id = ?", actualId); + properties.forEach((key, value) -> jdbc.update("insert into ingredient_property (ingredient_id, property_key, value_text) values (?, ?, ?) on conflict (ingredient_id, property_key) do update set value_text=excluded.value_text", actualId, key, value)); + } + } + + @Transactional + public void replaceEvidence(String ingredientId, List evidences) { + jdbc.update("delete from ingredient_evidence where ingredient_id = ?", ingredientId); + for (EvidenceView evidence : evidences) { + Long sourceId = jdbc.queryForObject("insert into evidence_source(source_type, title, url) values (?, ?, ?) returning id", + Long.class, evidence.sourceType(), evidence.title(), evidence.url()); + Long evidenceId = jdbc.queryForObject("insert into ingredient_evidence(ingredient_id, source_id, human_evidence, evidence_level, target_score, ingredient_specific, summary) values (?, ?, ?, ?, ?, ?, ?) returning id", + Long.class, + ingredientId, sourceId, evidence.humanEvidence(), evidence.evidenceLevel(), evidence.targetScore(), evidence.ingredientSpecific(), + evidence.summary()); + if (evidence.productForm() != null && !evidence.productForm().isBlank()) { + jdbc.update("insert into ingredient_evidence_parameter(evidence_id, parameter_key, value_text) values (?, 'PRODUCT_FORM', ?)", evidenceId, evidence.productForm()); + } + if (evidence.studyConcentration() != null && !evidence.studyConcentration().isBlank()) { + jdbc.update("insert into ingredient_evidence_parameter(evidence_id, parameter_key, value_min, value_max, value_unit) values (?, 'STUDY_CONCENTRATION', ?, ?, ?)", + evidenceId, decimal(evidence.studyConcentration()), decimal(evidence.studyConcentration()), evidence.studyConcentrationUnit()); + } + } + } + + @Transactional + public void replaceRanges(String ingredientId, List ranges, List conditions) { + jdbc.update("delete from ingredient_efficacy_profile where ingredient_id = ?", ingredientId); + for (EfficacyRangeView range : ranges) { + Long rangeId = jdbc.queryForObject("insert into ingredient_efficacy_profile(ingredient_id, target_key, product_type, concentration_min, concentration_max, concentration_unit, onset_concentration, irritation_concentration, notes) values (?, ?, ?, ?, ?, ?, ?, ?, ?) returning id", + Long.class, + ingredientId, range.targetKey(), range.productType(), decimal(range.concentrationMin()), decimal(range.concentrationMax()), range.concentrationUnit(), + decimal(range.onsetConcentration()), decimal(range.irritationConcentration()), range.notes()); + if (range.evidenceId() != null) { + jdbc.update("insert into ingredient_efficacy_profile_evidence(profile_id, evidence_id) values (?, ?)", rangeId, range.evidenceId()); + } + } + for (ConditionView condition : conditions) { + Long rangeId = jdbc.queryForObject("select id from ingredient_efficacy_profile where ingredient_id = ? and target_key = ? order by id limit 1", Long.class, ingredientId, condition.targetKey()); + if (rangeId != null) { + jdbc.update("insert into ingredient_efficacy_profile_condition(profile_id, parameter_key, value_text, value_min, value_max, value_unit, condition_mode, interpolation_allowed) values (?, ?, ?, ?, ?, ?, ?, ?) on conflict (profile_id, parameter_key) do update set value_text=excluded.value_text, value_min=excluded.value_min, value_max=excluded.value_max, value_unit=excluded.value_unit, condition_mode=excluded.condition_mode, interpolation_allowed=excluded.interpolation_allowed", + rangeId, condition.parameterKey(), condition.valueText(), decimal(condition.valueMin()), decimal(condition.valueMax()), condition.valueUnit(), condition.conditionMode(), condition.interpolationAllowed()); + } + } + } + + private BigDecimal decimal(String value) { + return value == null || value.isBlank() ? null : new BigDecimal(value.trim()); + } + + private Ingredient toIngredient(String id, String inci, String ko, String family) { + List aliases = jdbc.query("select alias from ingredient_alias where ingredient_id = ? order by id", ps -> ps.setString(1, id), + (rs, n) -> rs.getString("alias")); + Map effects = jdbc.query("select target_key, role from ingredient_effect where ingredient_id = ? order by id", ps -> ps.setString(1, id), + rs -> { + Map result = new java.util.LinkedHashMap<>(); + while (rs.next()) result.put(rs.getString("target_key"), rs.getString("role")); + return result; + }); + Map properties = jdbc.query("select property_key, coalesce(value_text, value_min::text, value_max::text) from ingredient_property where ingredient_id = ? order by id", ps -> ps.setString(1, id), + rs -> { + Map result = new java.util.LinkedHashMap<>(); + while (rs.next()) result.put(rs.getString(1), rs.getString(2)); + return result; + }); + return new Ingredient(id, inci, ko, family, + aliases, effects, properties, findEvidence(id), findRanges(id)); + } + + private List split(String value) { + if (value == null || value.isBlank()) return List.of(); + return java.util.Arrays.stream(value.split(",")).map(String::trim).filter(v -> !v.isBlank()).toList(); + } + + private String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private List findEvidence(String ingredientId) { + return jdbc.query("select ie.id, es.source_type, es.title, es.url, ie.human_evidence, ie.evidence_level, ie.target_score, ie.ingredient_specific, " + + "(select value_text from ingredient_evidence_parameter p where p.evidence_id = ie.id and p.parameter_key = 'PRODUCT_FORM'), " + + "(select coalesce(value_min::text, value_max::text, value_text) from ingredient_evidence_parameter p where p.evidence_id = ie.id and p.parameter_key = 'STUDY_CONCENTRATION'), " + + "(select value_unit from ingredient_evidence_parameter p where p.evidence_id = ie.id and p.parameter_key = 'STUDY_CONCENTRATION'), ie.summary " + + "from ingredient_evidence ie join evidence_source es on es.id = ie.source_id where ie.ingredient_id = ? order by ie.id", ps -> ps.setString(1, ingredientId), + (rs, n) -> new EvidenceView(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4), rs.getBoolean(5), + rs.getString(6), rs.getString(7), rs.getBoolean(8), rs.getString(9), + rs.getString(10), rs.getString(11), rs.getString(12))); + } + + private List findRanges(String ingredientId) { + return jdbc.query("select p.id, p.target_key, p.product_type, p.concentration_min, p.concentration_max, p.concentration_unit, p.onset_concentration, p.irritation_concentration, p.role, " + + "(select e.evidence_id from ingredient_efficacy_profile_evidence e where e.profile_id = p.id order by e.evidence_id limit 1), p.notes " + + "from ingredient_efficacy_profile p where p.ingredient_id = ? order by p.id", ps -> ps.setString(1, ingredientId), + (rs, n) -> new EfficacyRangeView(rs.getString(2), rs.getString(3), rs.getString(4), rs.getString(5), + rs.getString(6), rs.getString(7), rs.getString(8), (Long) rs.getObject(10), rs.getString(11), rs.getString(9), findConditions(rs.getLong(1), rs.getString(2)))); + } + + private List findConditions(Long rangeId, String targetKey) { + return jdbc.query("select parameter_key, value_text, value_min, value_max, value_unit, condition_mode, interpolation_allowed from ingredient_efficacy_profile_condition where profile_id = ? order by id", + ps -> ps.setLong(1, rangeId), (rs, n) -> new ConditionView(targetKey, rs.getString(1), rs.getString(2), rs.getString(3), rs.getString(4), rs.getString(5), rs.getString(6), rs.getBoolean(7))); + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/PropertyDefinitionView.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/PropertyDefinitionView.java new file mode 100644 index 0000000..9bde95b --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/PropertyDefinitionView.java @@ -0,0 +1,4 @@ +package com.seoulection.admin.ingredient.infrastructure.repository; + +public record PropertyDefinitionView(String propertyKey, String displayNameKo, String valueType, + String valueUnit, String description) { } diff --git a/src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java b/src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java new file mode 100644 index 0000000..8a0841d --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java @@ -0,0 +1,92 @@ +package com.seoulection.admin.ingredient.presentation.controller; + +import com.seoulection.admin.ingredient.application.service.IngredientService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import com.seoulection.admin.ingredient.presentation.dto.IngredientCreateRequest; +import jakarta.validation.Valid; +import org.springframework.validation.BindingResult; + +@Controller +public class IngredientController { + private final IngredientService service; + public IngredientController(IngredientService service) { this.service = service; } + + @GetMapping("/admin/ingredients") + public String page(Model model) { + if (!model.containsAttribute("request")) model.addAttribute("request", new IngredientCreateRequest()); + model.addAttribute("ingredients", service.getIngredients()); + model.addAttribute("propertyDefinitions", service.getPropertyDefinitions()); + return "ingredients"; + } + + @PostMapping("/admin/ingredients") + public String create(@Valid @ModelAttribute("request") IngredientCreateRequest request, + BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + // 등록 폼은 기본으로 접혀 있다 — 검증에 실패했으면 펼쳐서 오류를 보여 줘야 한다. + model.addAttribute("ingredients", service.getIngredients()); + model.addAttribute("propertyDefinitions", service.getPropertyDefinitions()); + model.addAttribute("registerFormOpen", true); + return "ingredients"; + } + service.create(request.getInciName(), request.getDisplayNameKo(), request.getFamily(), + request.getAliasesText(), request.getEffectsText(), request.getPropertiesText(), + request.getEvidenceText(), request.getEfficacyRangesText(), request.getEfficacyConditionsText()); + redirectAttributes.addFlashAttribute("successMessage", "성분을 등록했습니다."); + return "redirect:/admin/ingredients"; + } + + @GetMapping("/admin/ingredients/{id}/review") + public String reviewPage(@PathVariable String id, Model model) { + var ingredient = service.getIngredient(id); + IngredientCreateRequest request = new IngredientCreateRequest(); + request.setInciName(ingredient.getInciName()); + request.setDisplayNameKo(ingredient.getDisplayNameKo()); + request.setFamily(ingredient.getFamily()); + request.setAliasesText(String.join(", ", ingredient.getAliases())); + request.setEffectsText(ingredient.getEffects().entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()).collect(java.util.stream.Collectors.joining(", "))); + request.setPropertiesText(ingredient.getProperties().entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()).collect(java.util.stream.Collectors.joining(", "))); + request.setEvidenceText(ingredient.getEvidences().stream() + .map(e -> String.join("|", java.util.Objects.toString(e.sourceType(), ""), java.util.Objects.toString(e.title(), ""), + java.util.Objects.toString(e.url(), ""), Boolean.toString(e.humanEvidence()), java.util.Objects.toString(e.evidenceLevel(), ""), + java.util.Objects.toString(e.targetScore(), ""), Boolean.toString(e.ingredientSpecific()), java.util.Objects.toString(e.productForm(), ""), + java.util.Objects.toString(e.studyConcentration(), ""), java.util.Objects.toString(e.studyConcentrationUnit(), ""), java.util.Objects.toString(e.summary(), ""))) + .collect(java.util.stream.Collectors.joining("\n"))); + request.setEfficacyRangesText(ingredient.getEfficacyRanges().stream() + .map(e -> String.join("|", java.util.Objects.toString(e.targetKey(), ""), java.util.Objects.toString(e.productType(), ""), + java.util.Objects.toString(e.concentrationMin(), ""), java.util.Objects.toString(e.concentrationMax(), ""), java.util.Objects.toString(e.concentrationUnit(), ""), + java.util.Objects.toString(e.onsetConcentration(), ""), java.util.Objects.toString(e.irritationConcentration(), ""), + java.util.Objects.toString(e.evidenceId(), ""), java.util.Objects.toString(e.notes(), ""))) + .collect(java.util.stream.Collectors.joining("\n"))); + request.setEfficacyConditionsText(ingredient.getEfficacyRanges().stream() + .flatMap(r -> r.conditions().stream()) + .map(c -> String.join("|", c.targetKey(), c.parameterKey(), java.util.Objects.toString(c.valueMin(), ""), java.util.Objects.toString(c.valueMax(), ""), java.util.Objects.toString(c.valueUnit(), ""), java.util.Objects.toString(c.valueText(), ""), java.util.Objects.toString(c.conditionMode(), "EXACT_VALUE"), Boolean.toString(c.interpolationAllowed()))) + .collect(java.util.stream.Collectors.joining("\n"))); + model.addAttribute("ingredient", ingredient); + model.addAttribute("request", request); + model.addAttribute("propertyDefinitions", service.getPropertyDefinitions()); + return "ingredient-review"; + } + + @PostMapping("/admin/ingredients/{id}/review") + public String review(@PathVariable String id, @Valid @ModelAttribute("request") IngredientCreateRequest request, + BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + model.addAttribute("ingredient", service.getIngredient(id)); + return "ingredient-review"; + } + service.update(id, request.getInciName(), request.getDisplayNameKo(), request.getFamily(), + request.getAliasesText(), request.getEffectsText(), request.getPropertiesText(), + request.getEvidenceText(), request.getEfficacyRangesText(), request.getEfficacyConditionsText()); + redirectAttributes.addFlashAttribute("successMessage", "성분 검수 정보를 저장했습니다."); + return "redirect:/admin/ingredients"; + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/presentation/controller/PropertyDefinitionController.java b/src/main/java/com/seoulection/admin/ingredient/presentation/controller/PropertyDefinitionController.java new file mode 100644 index 0000000..0c86c4f --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/presentation/controller/PropertyDefinitionController.java @@ -0,0 +1,39 @@ +package com.seoulection.admin.ingredient.presentation.controller; + +import com.seoulection.admin.ingredient.infrastructure.repository.IngredientPostgresRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +public class PropertyDefinitionController { + private final IngredientPostgresRepository repository; + public PropertyDefinitionController(IngredientPostgresRepository repository) { this.repository = repository; } + + @GetMapping("/admin/ingredient-properties") + public String page(Model model) { + model.addAttribute("definitions", repository.findPropertyDefinitions()); + return "ingredient-properties"; + } + + @PostMapping("/admin/ingredient-properties") + public String save(@RequestParam String propertyKey, @RequestParam String displayNameKo, + @RequestParam String valueType, @RequestParam(required = false) String valueUnit, + @RequestParam(required = false) String description, RedirectAttributes redirect) { + repository.savePropertyDefinition(propertyKey, displayNameKo, valueType, valueUnit, description); + redirect.addFlashAttribute("successMessage", "특성 정의를 저장했습니다."); + return "redirect:/admin/ingredient-properties"; + } + + @PostMapping("/admin/ingredient-properties/{key}/delete") + public String delete(@PathVariable String key, RedirectAttributes redirect) { + try { + repository.deletePropertyDefinition(key); + redirect.addFlashAttribute("successMessage", "특성 정의를 삭제했습니다."); + } catch (IllegalArgumentException e) { + redirect.addFlashAttribute("errorMessage", e.getMessage()); + } + return "redirect:/admin/ingredient-properties"; + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java b/src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java new file mode 100644 index 0000000..81ed39f --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java @@ -0,0 +1,39 @@ +package com.seoulection.admin.ingredient.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class IngredientCreateRequest { + @NotBlank(message = "표준 성분명을 입력해 주세요.") @Size(max = 200) + @NotBlank(message = "INCI명을 입력해 주세요.") @Size(max = 200) + private String inciName; + @NotBlank(message = "한글명을 입력해 주세요.") @Size(max = 100) + private String displayNameKo; + @NotBlank(message = "성분 계열을 입력해 주세요.") @Size(max = 80) + private String family; + private String aliasesText; + private String effectsText; + private String propertiesText; + private String evidenceText; + private String efficacyRangesText; + private String efficacyConditionsText; + + public String getInciName() { return inciName; } + public void setInciName(String value) { inciName = value; } + public String getDisplayNameKo() { return displayNameKo; } + public void setDisplayNameKo(String value) { displayNameKo = value; } + public String getFamily() { return family; } + public void setFamily(String value) { family = value; } + public String getAliasesText() { return aliasesText; } + public void setAliasesText(String value) { aliasesText = value; } + public String getEffectsText() { return effectsText; } + public void setEffectsText(String value) { effectsText = value; } + public String getPropertiesText() { return propertiesText; } + public void setPropertiesText(String value) { propertiesText = value; } + public String getEvidenceText() { return evidenceText; } + public void setEvidenceText(String value) { evidenceText = value; } + public String getEfficacyRangesText() { return efficacyRangesText; } + public void setEfficacyRangesText(String value) { efficacyRangesText = value; } + public String getEfficacyConditionsText() { return efficacyConditionsText; } + public void setEfficacyConditionsText(String value) { efficacyConditionsText = value; } +} diff --git a/src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java b/src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java new file mode 100644 index 0000000..06a836d --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java @@ -0,0 +1,31 @@ +package com.seoulection.admin.product.application.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 목록 한 페이지. 템플릿에서 Spring의 Page를 직접 다루면 표현식이 지저분해져서 + * 화면이 실제로 쓰는 값만 추려 둔다. + */ +public record ProductPage( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + + public static ProductPage from(Page source) { + return new ProductPage(source.getContent(), source.getNumber(), source.getSize(), + source.getTotalElements(), source.getTotalPages()); + } + + public boolean isEmpty() { return content.isEmpty(); } + public boolean hasPrevious() { return page > 0; } + public boolean hasNext() { return page + 1 < totalPages; } + + /** "21-40 / 132"의 앞 두 숫자. 비어 있으면 0을 돌려준다. */ + public long firstItem() { return content.isEmpty() ? 0 : (long) page * size + 1; } + public long lastItem() { return content.isEmpty() ? 0 : (long) page * size + content.size(); } +} 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 60f2e29..2fa654b 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 @@ -1,6 +1,7 @@ package com.seoulection.admin.product.application.dto; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; import com.seoulection.admin.product.domain.enums.ProductStatus; import java.math.BigDecimal; @@ -10,29 +11,54 @@ public record ProductResult( String id, + String asin, String name, String brand, String category, + String description, + BigDecimal price, + String thumbnailUrl, + String productUrl, long mentionCount, BigDecimal adRatio, + BigDecimal adLikelihoodSum, + String ingredientSource, List ingredients, Map inciapiRawData, Instant analyzedAt, + List function, ProductStatus status ) { public static ProductResult from(Product product) { return new ProductResult( product.id(), + product.asin(), product.name(), product.brand(), product.category(), + product.description(), + product.price(), + product.thumbnailUrl(), + product.productUrl(), product.mentionCount(), product.adRatio(), + product.adLikelihoodSum(), + product.ingredientSource(), product.ingredients(), product.inciapiRawData(), product.analyzedAt(), + product.function(), product.status() ); } + + public int ingredientCount() { + return ingredients == null ? 0 : ingredients.size(); + } + + /** 기능성 검수를 마쳤고 확인된 유형이 있는가. 검수 자체를 했는지는 status가 안다. */ + public boolean hasFunction() { + return function != null && !function.isEmpty(); + } } 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 27935a7..a869496 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 @@ -1,30 +1,93 @@ package com.seoulection.admin.product.application.service; +import com.seoulection.admin.product.application.dto.ProductPage; import com.seoulection.admin.product.application.dto.ProductResult; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.domain.repository.ProductRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; +import java.util.Objects; @Service public class ProductService { + /** ObjectId 앞자리에 생성 시각이 들어가므로 _id 내림차순이 곧 최신순이다. */ + 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; public ProductService(ProductRepository repository) { this.repository = repository; + this.productIngredientRepository = null; + } + + @Autowired + public ProductService(ProductRepository repository, + com.seoulection.admin.product.infrastructure.repository.ProductIngredientPostgresRepository productIngredientRepository) { + this.repository = repository; + this.productIngredientRepository = productIngredientRepository; } public ProductResult register(String name, String brand, String category) { - Product product = Product.pending(name, brand, category); - return ProductResult.from(repository.insert(product)); + return ProductResult.from(repository.insert(Product.pending(name, brand, category))); + } + + public ProductResult register(String name, String brand, String category, List ingredients) { + ProductResult result = ProductResult.from(repository.insert(Product.pending(name, brand, category, ingredients))); + syncProductIngredients(result); + return result; + } + + /** 목록 한 페이지. statuses가 비어 있으면 상태 조건 없이 조회한다(전체 탭). */ + public ProductPage getProducts(List statuses, String keyword, int page, int size) { + var found = repository.find(keyword, statuses, PageRequest.of(page, size, NEWEST_FIRST)); + return ProductPage.from(found.map(ProductResult::from)); + } + + /** 탭 배지·소계용 상태별 건수. 검색 중이면 검색 결과 기준으로 센다. */ + public Map countByStatus(String keyword) { + return repository.countByStatus(keyword); + } + + public ProductResult getProduct(String id) { + return ProductResult.from(repository.findById(id)); + } + + /** 1단계 검수 — 전성분만 저장한다. 기능성(function)은 그대로 남는다. */ + public ProductResult reviewIngredients(String id, List ingredients, boolean ingredientNotFound) { + Product product = repository.findById(id); + ProductResult result = ProductResult.from(repository.save(product.reviewIngredients(ingredients, ingredientNotFound))); + syncProductIngredients(result); + return result; + } + + /** 2단계 검수 — 식약처 기능성만 저장한다. 빈 목록은 "확인했으나 기능성 아님"이다. */ + public ProductResult reviewFunction(String id, List function) { + Product product = repository.findById(id); + return ProductResult.from(repository.save(product.reviewFunction(parseFunction(function)))); + } + + public void delete(String id) { + repository.findById(id); // 없는 id면 여기서 IllegalArgumentException으로 걸린다. + repository.deleteById(id); + } + + private List parseFunction(List values) { + return values == null ? List.of() : values.stream().filter(Objects::nonNull) + .map(ProductFunctionalCategory::from).distinct().toList(); } - public List getProducts() { - return repository.findAll() - .stream() - .map(ProductResult::from) - .toList(); + private void syncProductIngredients(ProductResult result) { + if (productIngredientRepository != null) { + productIngredientRepository.replace(result.id(), result.ingredients(), result.ingredientSource()); + } } } 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 ccfc1e5..27d3fca 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 @@ -1,6 +1,7 @@ package com.seoulection.admin.product.domain.entity; import com.seoulection.admin.product.domain.enums.ProductCategory; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; import com.seoulection.admin.product.domain.enums.ProductStatus; import java.math.BigDecimal; @@ -9,84 +10,84 @@ import java.util.Map; import java.util.Objects; +/** + * products 컬렉션의 제품 한 건. + * + *

필드는 셋으로 나뉜다 — (1) 수집 단계가 채우는 카탈로그 정보(asin·description·price· + * thumbnailUrl·productUrl), (2) 분석 파이프라인이 채우는 값(mentionCount·adRatio· + * adLikelihoodSum·inciapiRawData·analyzedAt), (3) 어드민이 검수로 채우는 값(ingredients· + * ingredientSource·function). 어드민 화면이 직접 쓰는 건 (3)뿐이고 나머지는 읽기 전용이다. + * + *

기능성 검수 여부를 담는 별도 필드는 두지 않는다. {@link ProductStatus}가 그 역할을 한다 — + * INGREDIENTS_ADDED면 아직 안 본 것이고, READY_FOR_INCIAPI 이후면 본 것이다. 그리고 "봤는데 + * 기능성이 아니었다"와 "기능성이 확인됐다"는 {@code function}이 비었는지로 갈린다. + */ public class Product { private final String id; + private final String asin; private final String name; private final String brand; private final ProductCategory category; + private final String description; + private final BigDecimal price; + private final String thumbnailUrl; + private final String productUrl; private final long mentionCount; private final BigDecimal adRatio; + private final BigDecimal adLikelihoodSum; + private final String ingredientSource; private final List ingredients; private final Map inciapiRawData; private final Instant analyzedAt; + private final List function; private final ProductStatus status; - private Product( - String id, - String name, - String brand, - ProductCategory category, - long mentionCount, - BigDecimal adRatio, - List ingredients, - Map inciapiRawData, - Instant analyzedAt, - ProductStatus status - ) { - this.id = id; - this.name = requireText(name, "name"); - this.brand = requireText(brand, "brand"); - this.category = Objects.requireNonNull(category); - this.mentionCount = mentionCount; - this.adRatio = Objects.requireNonNull(adRatio); - this.ingredients = ingredients; - this.inciapiRawData = inciapiRawData; - this.analyzedAt = analyzedAt; - this.status = Objects.requireNonNull(status); + private Product(Builder builder) { + this.id = builder.id; + this.asin = builder.asin; + this.name = requireText(builder.name, "name"); + this.brand = requireText(builder.brand, "brand"); + this.category = Objects.requireNonNull(builder.category); + this.description = builder.description; + this.price = builder.price; + this.thumbnailUrl = builder.thumbnailUrl; + this.productUrl = builder.productUrl; + this.mentionCount = builder.mentionCount; + this.adRatio = builder.adRatio == null ? BigDecimal.ZERO : builder.adRatio; + this.adLikelihoodSum = builder.adLikelihoodSum; + this.ingredientSource = builder.ingredientSource; + this.ingredients = builder.ingredients; + this.inciapiRawData = builder.inciapiRawData; + this.analyzedAt = builder.analyzedAt; + this.function = builder.function == null ? List.of() : List.copyOf(builder.function); + this.status = Objects.requireNonNull(builder.status); } + public static Builder builder() { + return new Builder(); + } + + /** 어드민이 새로 등록하는 제품. 성분을 같이 넣으면 바로 기능성 확인 단계로 간다. */ public static Product pending(String name, String brand, String category) { - return new Product( - null, - name, - brand, - ProductCategory.from(category), - 0L, - BigDecimal.ZERO, - null, - null, - null, - ProductStatus.PENDING - ); + return pending(name, brand, category, null); } - public static Product restore( - String id, - String name, - String brand, - String category, - long mentionCount, - BigDecimal adRatio, - List ingredients, - Map inciapiRawData, - Instant analyzedAt, - ProductStatus status - ) { - return new Product( - id, - name, - brand, - ProductCategory.from(category), - mentionCount, - adRatio, - ingredients, - inciapiRawData, - analyzedAt, - status - ); + public static Product pending(String name, String brand, String category, List ingredients) { + boolean hasIngredients = ingredients != null && !ingredients.isEmpty(); + return builder() + .name(name) + .brand(brand) + .category(ProductCategory.from(category)) + .ingredients(hasIngredients ? ingredients : null) + .ingredientSource(hasIngredients ? ADMIN_SOURCE : null) + .status(hasIngredients ? ProductStatus.INGREDIENTS_ADDED : ProductStatus.PENDING) + .build(); } + /** 어드민이 직접 입력한 성분임을 표시하는 값. 파이프라인은 자기 출처 값을 따로 넣는다. */ + public static final String ADMIN_SOURCE = "ADMIN"; + private static String requireText(String value, String fieldName) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(fieldName + "은(는) 필수입니다."); @@ -94,43 +95,125 @@ private static String requireText(String value, String fieldName) { return value.trim(); } - public String id() { - return id; - } - - public String name() { - return name; - } - - public String brand() { - return brand; - } - - public String category() { - return category.value(); - } - - public long mentionCount() { - return mentionCount; - } - - public BigDecimal adRatio() { - return adRatio; - } - - public List ingredients() { - return ingredients; + public String id() { return id; } + public String asin() { return asin; } + public String name() { return name; } + public String brand() { return brand; } + public String category() { return category.value(); } + public String description() { return description; } + public BigDecimal price() { return price; } + public String thumbnailUrl() { return thumbnailUrl; } + public String productUrl() { return productUrl; } + public long mentionCount() { return mentionCount; } + public BigDecimal adRatio() { return adRatio; } + public BigDecimal adLikelihoodSum() { return adLikelihoodSum; } + public String ingredientSource() { return ingredientSource; } + public List ingredients() { return ingredients; } + public Map inciapiRawData() { return inciapiRawData; } + public Instant analyzedAt() { return analyzedAt; } + public List function() { return function; } + public ProductStatus status() { return status; } + + /** + * 1단계 검수 — 전성분만 갱신한다. function은 손대지 않는다. + * + *

이미 기능성 검수를 지난 제품(READY_FOR_INCIAPI 이후)은 성분을 고쳐도 앞 단계로 + * 되돌리지 않는다. 성분 오타 하나 고쳤다고 기능성을 다시 보게 만들 이유가 없다. + */ + public Product reviewIngredients(List ingredients, boolean ingredientNotFound) { + ProductStatus nextStatus; + if (ingredientNotFound) { + nextStatus = ProductStatus.NOT_FOUND; + } else if (ingredients == null || ingredients.isEmpty()) { + // 빈 저장은 아무것도 확인하지 못한 것이다. INSUFFICIENT_INGREDIENTS는 "크롤링은 됐는데 + // 성분이 5개 미만"이라는 파이프라인의 판정이라 어드민 저장으로 만들어 내면 안 된다. + nextStatus = status; + } else if (status.functionalReviewDone()) { + nextStatus = status; + } else { + nextStatus = ProductStatus.INGREDIENTS_ADDED; + } + boolean cleared = ingredientNotFound || ingredients == null || ingredients.isEmpty(); + return toBuilder() + .ingredients(cleared ? null : ingredients) + .ingredientSource(cleared ? null : ADMIN_SOURCE) + .status(nextStatus) + .build(); } - public Map inciapiRawData() { - return inciapiRawData; + /** + * 2단계 검수 — 식약처 기능성 결과만 갱신한다. 성분은 손대지 않는다. + * + *

빈 목록도 "검수했고 기능성이 아니었다"는 결과이므로 상태는 똑같이 전진한다. + * 검수 행위가 status를 옮기고, 무엇이 확인됐는지는 function이 담는다. + * + *

NOT_FOUND는 그대로 둔다 — 성분을 못 찾았다는 사실이 기능성 검수로 뒤집히지는 않는다. + */ + public Product reviewFunction(List function) { + ProductStatus nextStatus; + if (status == ProductStatus.NOT_FOUND + || status == ProductStatus.COMPLETE + || status == ProductStatus.SUMMARIZED) { + nextStatus = status; + } else if (ingredients == null || ingredients.isEmpty()) { + nextStatus = status; // 성분 없이 기능성만 확정할 수는 없다 — 상태를 그대로 둔다. + } else { + nextStatus = ProductStatus.READY_FOR_INCIAPI; + } + return toBuilder().function(function).status(nextStatus).build(); } - public Instant analyzedAt() { - return analyzedAt; + public Builder toBuilder() { + return new Builder() + .id(id).asin(asin).name(name).brand(brand).category(category) + .description(description).price(price).thumbnailUrl(thumbnailUrl).productUrl(productUrl) + .mentionCount(mentionCount).adRatio(adRatio).adLikelihoodSum(adLikelihoodSum) + .ingredientSource(ingredientSource).ingredients(ingredients) + .inciapiRawData(inciapiRawData).analyzedAt(analyzedAt) + .function(function).status(status); } - public ProductStatus status() { - return status; + /** 필드가 18개라 위치 인자 생성자는 읽을 수 없다 — 복원·수정 모두 이 빌더를 쓴다. */ + public static class Builder { + private String id; + private String asin; + private String name; + private String brand; + private ProductCategory category; + private String description; + private BigDecimal price; + private String thumbnailUrl; + private String productUrl; + private long mentionCount; + private BigDecimal adRatio; + private BigDecimal adLikelihoodSum; + private String ingredientSource; + private List ingredients; + private Map inciapiRawData; + private Instant analyzedAt; + private List function; + private ProductStatus status; + + 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 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; } + public Builder description(String v) { this.description = v; return this; } + public Builder price(BigDecimal v) { this.price = v; return this; } + public Builder thumbnailUrl(String v) { this.thumbnailUrl = v; return this; } + public Builder productUrl(String v) { this.productUrl = v; return this; } + public Builder mentionCount(long v) { this.mentionCount = v; return this; } + public Builder adRatio(BigDecimal v) { this.adRatio = v; return this; } + public Builder adLikelihoodSum(BigDecimal v) { this.adLikelihoodSum = v; return this; } + public Builder ingredientSource(String v) { this.ingredientSource = v; return this; } + public Builder ingredients(List v) { this.ingredients = v; return this; } + public Builder inciapiRawData(Map v) { this.inciapiRawData = v; return this; } + public Builder analyzedAt(Instant v) { this.analyzedAt = v; return this; } + public Builder function(List v) { this.function = v; return this; } + public Builder status(ProductStatus v) { this.status = v; return this; } + + public Product build() { return new Product(this); } } } diff --git a/src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java b/src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java new file mode 100644 index 0000000..0105750 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java @@ -0,0 +1,31 @@ +package com.seoulection.admin.product.domain.enums; + +import java.util.Arrays; + +/** 식약처 기능성화장품 표시 목적 분류. 성분 효능(ingredient_effect)과는 별도다. */ +public enum ProductFunctionalCategory { + WHITENING("WHITENING", "미백"), + WRINKLE_IMPROVEMENT("WRINKLE_IMPROVEMENT", "주름 개선"), + UV_PROTECTION("UV_PROTECTION", "자외선 차단"), + ACNE_RELIEF("ACNE_RELIEF", "여드름성 피부 완화"), + SKIN_BARRIER_RECOVERY("SKIN_BARRIER_RECOVERY", "피부장벽 기능 회복"), + STRETCH_MARKS("STRETCH_MARKS", "튼살 완화"); + + private final String code; + private final String displayName; + + ProductFunctionalCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String code() { return code; } + public String displayName() { return displayName; } + + public static ProductFunctionalCategory from(String value) { + return Arrays.stream(values()) + .filter(category -> category.code.equalsIgnoreCase(value.trim())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 기능성 분류입니다: " + value)); + } +} diff --git a/src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java new file mode 100644 index 0000000..d25c423 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java @@ -0,0 +1,76 @@ +package com.seoulection.admin.product.domain.enums; + +import java.util.Arrays; +import java.util.List; + +/** + * 목록 화면의 작업 단계(탭). 여러 {@link ProductStatus}를 "지금 누가 움직여야 하는가"로 묶는다. + * + *

DB에는 status만 저장한다 — stage는 저장되지 않는 화면 개념이고, status로부터 유도된다 + * ({@link ProductStatus#stage()}). 그래서 상태가 늘어도 그 매핑 한 줄만 고치면 탭·배지·조회가 + * 모두 따라온다. + * + *

URL에는 {@code slug}가 나간다({@code ?stage=ingredient-review}). 화면·컨트롤러가 + * "ingredients" 같은 문자열을 직접 비교하지 않게 하려고 enum으로 둔다. + */ +public enum ProductStage { + + INGREDIENT_REVIEW("ingredient-review", "1 성분 보완", "내가 할 일", + "파이프라인이 성분을 못 가져와 사람이 확인해야 하는 제품입니다."), + + FUNCTIONAL_REVIEW("functional-review", "2 기능성 확인", "내가 할 일", + "전성분이 채워져 식약처 기능성 여부만 확인하면 되는 제품입니다."), + + PIPELINE("pipeline", "파이프라인", "파이프라인", + "파이프라인이 움직일 차례입니다. 수집이 늦어지면 성분을 직접 입력해 앞당길 수 있습니다."), + + COMPLETED("completed", "분석 완료", "파이프라인", + "분석까지 마친 제품입니다."), + + INGREDIENT_FAILED("ingredient-failed", "성분 확보 실패", "", + "파이프라인이 성분을 확보하지 못한 제품입니다. 성분 부족은 크롤링된 성분이 5개 미만," + + " 성분 정보 없음은 성분 정보를 찾지 못한 경우입니다."); + + // group: 탭 묶음 머리말. 빈 문자열이면 구분선만 긋고 이름은 붙이지 않는다. + private final String slug; + private final String label; + private final String group; + private final String description; + + ProductStage(String slug, String label, String group, String description) { + this.slug = slug; + this.label = label; + this.group = group; + this.description = description; + } + + public String slug() { return slug; } + public String label() { return label; } + public String group() { return group; } + public String description() { return description; } + + /** + * 건수 배지의 강조 색. 밀리면 곤란한 단계(어드민 작업 큐, 확보 실패)만 눈에 띄게 한다 — + * 파이프라인이 알아서 굴리는 단계까지 붉게 칠하면 강조가 의미를 잃는다. + */ + public String countTone() { + return switch (this) { + case INGREDIENT_REVIEW, INGREDIENT_FAILED -> "is-alert"; + case FUNCTIONAL_REVIEW -> "is-attention"; + case PIPELINE, COMPLETED -> ""; + }; + } + + /** 이 단계에 속하는 상태들. ProductStatus.stage()에서 유도해 매핑을 한 곳에만 둔다. */ + public List statuses() { + return Arrays.stream(ProductStatus.values()).filter(status -> status.stage() == this).toList(); + } + + /** URL slug로 단계를 찾는다. 모르는 값(손으로 고친 URL, 'all')이면 null. */ + public static ProductStage from(String slug) { + return Arrays.stream(values()) + .filter(stage -> stage.slug.equals(slug)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java index 03523cc..6c7f244 100644 --- a/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java +++ b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java @@ -1,10 +1,104 @@ package com.seoulection.admin.product.domain.enums; +/** + * 제품의 파이프라인 상태. + * + *

어드민이 옮기는 상태와 파이프라인이 옮기는 상태가 섞여 있다. 특히 아래 둘은 + * 파이프라인만 만드는 결과라 어드민 화면에서 만들어 내면 안 된다. + *

    + *
  • {@code INSUFFICIENT_INGREDIENTS} — 크롤링은 됐는데 성분이 5개 미만
  • + *
  • {@code NOT_FOUND} — 성분 정보 자체를 찾지 못함
  • + *
+ */ public enum ProductStatus { PENDING, + NEED_MANUAL_REVIEW, NOT_FOUND, INSUFFICIENT_INGREDIENTS, + INGREDIENTS_ADDED, + READY_FOR_INCIAPI, READY_FOR_ANALYSIS, COMPLETE, - SUMMARIZED + SUMMARIZED; + + public String displayName() { + return switch (this) { + case PENDING -> "대기 중"; + case NEED_MANUAL_REVIEW -> "성분 수동 확인 필요"; + case NOT_FOUND -> "성분 정보 없음"; + case INSUFFICIENT_INGREDIENTS -> "성분 부족"; + case INGREDIENTS_ADDED -> "성분 입력 완료"; + case READY_FOR_INCIAPI -> "INCI API 준비"; + case READY_FOR_ANALYSIS -> "분석 준비"; + case COMPLETE -> "분석 완료"; + case SUMMARIZED -> "요약 완료"; + }; + } + + /** + * 목록의 상태 배지 색. displayName()과 같은 결의 표현용 메서드다 — + * 템플릿에서 상태 이름을 일일이 비교하는 th:if 더미를 만들지 않으려고 여기 둔다. + */ + public String tone() { + return switch (this) { + case PENDING -> "neutral"; + case NEED_MANUAL_REVIEW, INSUFFICIENT_INGREDIENTS -> "warning"; + case NOT_FOUND -> "danger"; + case INGREDIENTS_ADDED, READY_FOR_INCIAPI, READY_FOR_ANALYSIS -> "accent"; + case COMPLETE, SUMMARIZED -> "success"; + }; + } + + /** + * 이 상태가 속한 작업 단계(탭). 단계가 답하는 질문은 "지금 누가 움직여야 하는가"다. + * + *

READY_FOR_INCIAPI와 READY_FOR_ANALYSIS를 각각 탭으로 쪼개지 않는 이유: 둘 다 어드민이 + * 손댈 게 없어 눌러도 할 일이 없는 탭만 늘어난다. 세부 상태는 표의 상태 배지와 탭 안 + * 소계 칩으로 드러난다. PENDING(수집 대기)도 같은 이유로 PIPELINE에 둔다. + */ + public ProductStage stage() { + return switch (this) { + case NEED_MANUAL_REVIEW -> ProductStage.INGREDIENT_REVIEW; + case INGREDIENTS_ADDED -> ProductStage.FUNCTIONAL_REVIEW; + case PENDING, READY_FOR_INCIAPI, READY_FOR_ANALYSIS -> ProductStage.PIPELINE; + case COMPLETE, SUMMARIZED -> ProductStage.COMPLETED; + // 성분을 확보하지 못한 두 결말. 다시 시도해 볼 대상이라 한곳에 모아 둔다. + case INSUFFICIENT_INGREDIENTS, NOT_FOUND -> ProductStage.INGREDIENT_FAILED; + }; + } + + /** + * 어드민 작업이 필요한 상태가 열어야 할 workflow 단계. 작업이 없으면 null이다. + * + *

성분과 기능성은 근거 자료가 다른 별개의 작업이라 한 폼에 같이 두지 않는다. 파이프라인이 + * 굴리는 중이거나 이미 끝난 제품은 null을 돌려 상세 화면으로 보낸다 — 할 일이 없는 제품을 + * 작업 화면에 넣으면 "뭘 하라는 거지"가 된다. + * + *

NOT_FOUND는 "성분을 못 찾았다"는 뜻이므로 다시 성분 단계로 돌려보낸다. + */ + public String workflowStep() { + return switch (this) { + case NEED_MANUAL_REVIEW, INSUFFICIENT_INGREDIENTS, NOT_FOUND -> "ingredients"; + case INGREDIENTS_ADDED -> "functional"; + case PENDING, READY_FOR_INCIAPI, READY_FOR_ANALYSIS, COMPLETE, SUMMARIZED -> null; + }; + } + + /** 목록의 "검수" 열 링크 문구. */ + public String actionLabel() { + String step = workflowStep(); + if (step == null) { + return "상세 보기"; + } + return "ingredients".equals(step) ? "성분 보완" : "기능성 확인"; + } + + /** 기능성 검수를 이미 지났는가. 별도 필드 대신 이 판정이 functional_review_status를 대신한다. */ + public boolean functionalReviewDone() { + return switch (this) { + case READY_FOR_INCIAPI, READY_FOR_ANALYSIS, COMPLETE, SUMMARIZED -> true; + default -> false; + }; + } + } diff --git a/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java b/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java index 5ae41f5..9ba8144 100644 --- a/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java +++ b/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java @@ -1,12 +1,32 @@ package com.seoulection.admin.product.domain.repository; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Map; public interface ProductRepository { Product insert(Product product); - List findAll(); + Product findById(String id); + + Product save(Product product); + + void deleteById(String id); + + /** + * 목록 한 페이지. 검색어·상태 필터를 모두 Mongo 쪽에서 적용한다 — + * 전체를 메모리에 올려 자바에서 거르면 제품이 늘어날수록 목록 화면이 먼저 무너진다. + * + * @param keyword 제품명·브랜드 부분 일치. 비어 있으면 전체. + * @param statuses 이 상태들만. 비어 있으면 전체. + */ + Page find(String keyword, List statuses, Pageable pageable); + + /** 검색어를 적용한 상태별 건수. 탭 배지와 소계를 질의 한 번으로 채운다. */ + Map countByStatus(String keyword); } 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 15a8991..897620d 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 @@ -1,8 +1,10 @@ package com.seoulection.admin.product.infrastructure.document; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; import com.seoulection.admin.product.domain.enums.ProductStatus; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.FieldType; @@ -18,9 +20,23 @@ public class ProductDocument { @Id private String id; + /** 수집 원본(아마존)의 제품 식별자. 같은 제품을 두 번 넣지 않기 위한 자연키다. */ + @Indexed(unique = true, sparse = true) + private String asin; + private String name; private String brand; private String category; + private String description; + + @Field(name = "price", targetType = FieldType.DECIMAL128) + private BigDecimal price; + + @Field("thumbnail_url") + private String thumbnailUrl; + + @Field("product_url") + private String productUrl; @Field("mention_count") private long mentionCount; @@ -28,6 +44,13 @@ public class ProductDocument { @Field(name = "ad_ratio", targetType = FieldType.DECIMAL128) private BigDecimal adRatio; + @Field(name = "ad_likelihood_sum", targetType = FieldType.DECIMAL128) + private BigDecimal adLikelihoodSum; + + /** 성분을 어디서 얻었는지. 어드민이 직접 입력하면 ADMIN, 파이프라인은 자기 값을 넣는다. */ + @Field("ingredient_source") + private String ingredientSource; + private List ingredients; @Field("inciapi_raw_data") @@ -36,6 +59,10 @@ public class ProductDocument { @Field("analyzed_at") private Instant analyzedAt; + /** 식약처 기능성 유형. 비어 있으면 "검수했으나 기능성 아님"이다 — 검수 여부는 status가 안다. */ + private List function; + + @Indexed private ProductStatus status; protected ProductDocument() { @@ -43,14 +70,22 @@ protected ProductDocument() { private ProductDocument(Product product) { this.id = product.id(); + this.asin = product.asin(); this.name = product.name(); this.brand = product.brand(); this.category = product.category(); + this.description = product.description(); + this.price = product.price(); + this.thumbnailUrl = product.thumbnailUrl(); + this.productUrl = product.productUrl(); this.mentionCount = product.mentionCount(); this.adRatio = product.adRatio(); + this.adLikelihoodSum = product.adLikelihoodSum(); + this.ingredientSource = product.ingredientSource(); this.ingredients = product.ingredients(); this.inciapiRawData = product.inciapiRawData(); this.analyzedAt = product.analyzedAt(); + this.function = product.function(); this.status = product.status(); } @@ -59,17 +94,25 @@ public static ProductDocument fromDomain(Product product) { } public Product toDomain() { - return Product.restore( - id, - name, - brand, - category, - mentionCount, - adRatio, - ingredients, - inciapiRawData, - analyzedAt, - status - ); + return Product.builder() + .id(id) + .asin(asin) + .name(name) + .brand(brand) + .category(category) + .description(description) + .price(price) + .thumbnailUrl(thumbnailUrl) + .productUrl(productUrl) + .mentionCount(mentionCount) + .adRatio(adRatio) + .adLikelihoodSum(adLikelihoodSum) + .ingredientSource(ingredientSource) + .ingredients(ingredients) + .inciapiRawData(inciapiRawData) + .analyzedAt(analyzedAt) + .function(function) + .status(status == null ? ProductStatus.PENDING : status) + .build(); } } 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 new file mode 100644 index 0000000..4023b97 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductIngredientPostgresRepository.java @@ -0,0 +1,32 @@ +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/infrastructure/repository/ProductMongoRepository.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductMongoRepository.java deleted file mode 100644 index 460b54b..0000000 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductMongoRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.seoulection.admin.product.infrastructure.repository; - -import com.seoulection.admin.product.infrastructure.document.ProductDocument; -import org.springframework.data.mongodb.repository.MongoRepository; - -interface ProductMongoRepository extends MongoRepository { -} diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java index d74b779..9e648ed 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java @@ -1,32 +1,110 @@ package com.seoulection.admin.product.infrastructure.repository; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.domain.repository.ProductRepository; import com.seoulection.admin.product.infrastructure.document.ProductDocument; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; +import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +/** + * 파생 쿼리(findByXxx) 대신 MongoTemplate을 쓰는 이유: 목록은 검색어 × 상태 필터 × 페이지의 + * 조합이라 메서드 이름으로 표현하면 금방 감당이 안 된다. 조건을 Criteria로 조립하면 조합이 + * 늘어도 메서드 하나로 끝난다. + */ @Repository public class ProductRepositoryImpl implements ProductRepository { - private final ProductMongoRepository mongoRepository; + /** 정규식 메타문자. 검색어를 그대로 regex에 넣으면 "("만 쳐도 쿼리가 깨진다. */ + private static final Pattern REGEX_META = Pattern.compile("[\\\\^$.|?*+()\\[\\]{}]"); - public ProductRepositoryImpl(ProductMongoRepository mongoRepository) { - this.mongoRepository = mongoRepository; + private final MongoTemplate mongoTemplate; + + public ProductRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; } @Override public Product insert(Product product) { - return mongoRepository.insert(ProductDocument.fromDomain(product)).toDomain(); + return mongoTemplate.insert(ProductDocument.fromDomain(product)).toDomain(); + } + + @Override + public Product findById(String id) { + ProductDocument found = mongoTemplate.findById(id, ProductDocument.class); + if (found == null) { + throw new IllegalArgumentException("제품을 찾을 수 없습니다: " + id); + } + return found.toDomain(); + } + + @Override + public Product save(Product product) { + return mongoTemplate.save(ProductDocument.fromDomain(product)).toDomain(); + } + + @Override + public void deleteById(String id) { + mongoTemplate.remove(new Query(Criteria.where("_id").is(id)), ProductDocument.class); } @Override - public List findAll() { - return mongoRepository.findAll(Sort.by(Sort.Direction.DESC, "id")) + public Page find(String keyword, List statuses, Pageable pageable) { + Query query = new Query(); + criteria(keyword, statuses).forEach(query::addCriteria); + long total = mongoTemplate.count(query, ProductDocument.class); + List content = mongoTemplate.find(query.with(pageable), ProductDocument.class) .stream() .map(ProductDocument::toDomain) .toList(); + return new PageImpl<>(content, pageable, total); + } + + @Override + public Map countByStatus(String keyword) { + List criteria = criteria(keyword, List.of()); + List stages = new ArrayList<>(); + criteria.forEach(c -> stages.add(Aggregation.match(c))); + stages.add(Aggregation.group("status").count().as("count")); + + Map counts = new EnumMap<>(ProductStatus.class); + mongoTemplate.aggregate(Aggregation.newAggregation(stages), ProductDocument.class, StatusCount.class) + .forEach(row -> { + if (row.id() != null) { + counts.merge(ProductStatus.valueOf(row.id()), row.count(), Long::sum); + } + }); + return counts; + } + + private List criteria(String keyword, List statuses) { + List criteria = new ArrayList<>(); + if (keyword != null && !keyword.isBlank()) { + String escaped = REGEX_META.matcher(keyword.trim()).replaceAll("\\\\$0"); + criteria.add(new Criteria().orOperator( + Criteria.where("name").regex(escaped, "i"), + Criteria.where("brand").regex(escaped, "i"))); + } + if (statuses != null && !statuses.isEmpty()) { + criteria.add(Criteria.where("status").in(statuses)); + } + return criteria; + } + + /** group 단계의 결과 한 줄. _id에 status 문자열이, count에 건수가 들어온다. */ + private record StatusCount(String id, long count) { } } 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 ad13c53..fd1d7fa 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,8 @@ package com.seoulection.admin.product.presentation.controller; 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.presentation.dto.ProductRegisterRequest; import jakarta.validation.Valid; import org.springframework.stereotype.Controller; @@ -8,27 +10,117 @@ import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +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 tools.jackson.databind.ObjectMapper; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; @Controller public class ProductController { + /** 한 페이지에 보여 줄 건수. 표 한 화면에 들어오면서 스크롤이 과하지 않은 값. */ + private static final int PAGE_SIZE = 25; + private final ProductService service; + private final ObjectMapper objectMapper; - public ProductController(ProductService service) { + public ProductController(ProductService service, ObjectMapper objectMapper) { this.service = service; + this.objectMapper = objectMapper; } + /** + * 목록 화면. 기본 단계가 전체가 아니라 성분 보완인 이유: 이 화면을 여는 이유는 대개 + * "지금 내가 처리할 게 뭔가"이지 "전부 몇 개인가"가 아니다. 전체는 맨 끝에 둔다. + * + *

stage는 탭(작업 큐), status는 그 안의 단일 상태다. 함께 쓰면 정밀 조회가 된다 — + * 예: {@code ?stage=ingredient-failed&status=NOT_FOUND}. + */ @GetMapping("/admin/products") - public String page(Model model) { + public String page(@RequestParam(defaultValue = "ingredient-review") String stage, + @RequestParam(required = false) String status, + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "0") int page, + Model model) { if (!model.containsAttribute("request")) { model.addAttribute("request", new ProductRegisterRequest()); } - model.addAttribute("products", service.getProducts()); + populateProductList(model, stage, status, q, page); return "products"; } + /** + * @param stageSlug 탭. 모르는 값이거나 "all"이면 상태 조건 없이 전체를 본다. + * @param status 단계 안에서 한 상태만 보고 싶을 때. stage와 함께 쓴다. + */ + private void populateProductList(Model model, String stageSlug, String status, String query, int page) { + boolean searching = query != null && !query.isBlank(); + String keyword = searching ? query.trim() : null; + ProductStage stage = ProductStage.from(stageSlug); + ProductStatus exact = parseStatus(status); + + model.addAttribute("page", service.getProducts(statusesFor(stage, exact), keyword, + Math.max(page, 0), PAGE_SIZE)); + model.addAttribute("stages", List.of(ProductStage.values())); + model.addAttribute("selectedStage", stage); + model.addAttribute("selectedStageSlug", stage == null ? "all" : stage.slug()); + model.addAttribute("selectedStatus", exact == null ? "" : exact.name()); + model.addAttribute("query", searching ? keyword : ""); + + // 탭 배지·소계를 집계 질의 한 번으로 채운다. 검색 중이면 검색 결과 기준으로 센다 — + // "이 브랜드 중 성분 보완이 몇 건인가"가 검색을 쓰는 이유이므로 전체 건수를 보이면 어긋난다. + Map byStatus = service.countByStatus(keyword); + model.addAttribute("statusCounts", byStatus); + model.addAttribute("stageCounts", stageCounts(byStatus)); + model.addAttribute("substatStatuses", stage == null ? List.of(ProductStatus.values()) : stage.statuses()); + model.addAttribute("productCount", byStatus.values().stream().mapToLong(Long::longValue).sum()); + } + + /** + * 조회할 상태 집합. stage는 여러 상태를 묶은 작업 큐라서, 그 안의 한 상태만 보고 싶을 때가 + * 있다 — 예: 성분 확보 실패 단계에서 NOT_FOUND만. status 파라미터가 그 역할이다. + * 그 단계에 속하지 않는 상태가 들어오면 무시하고 단계 전체를 보여 준다. + */ + private List statusesFor(ProductStage stage, ProductStatus exact) { + List inStage = stage == null ? List.of() : stage.statuses(); + if (exact == null) { + return inStage; + } + if (inStage.isEmpty() || inStage.contains(exact)) { + return List.of(exact); + } + return inStage; + } + + private ProductStatus parseStatus(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return ProductStatus.valueOf(value); + } catch (IllegalArgumentException e) { + return null; // 손으로 URL을 고친 경우다 — 목록을 깨뜨리지 말고 탭 전체를 보여 준다. + } + } + + /** + * 단계별 건수. 키를 enum이 아니라 slug 문자열로 두는 이유: SpEL의 맵 조회가 enum 키를 + * 제대로 잡지 못해 템플릿에서 전부 null로 나온다. slug는 URL에도 그대로 쓰는 값이다. + */ + private Map stageCounts(Map byStatus) { + Map stages = new LinkedHashMap<>(); + Arrays.stream(ProductStage.values()).forEach(stage -> stages.put(stage.slug(), 0L)); + Arrays.stream(ProductStatus.values()) + .forEach(status -> stages.merge(status.stage().slug(), byStatus.getOrDefault(status, 0L), Long::sum)); + return stages; + } + @PostMapping("/admin/products") public String register( @Valid @ModelAttribute("request") ProductRegisterRequest request, @@ -37,12 +129,126 @@ public String register( RedirectAttributes redirectAttributes ) { if (bindingResult.hasErrors()) { - model.addAttribute("products", service.getProducts()); + populateProductList(model, "ingredient-review", null, null, 0); + model.addAttribute("registerFormOpen", true); return "products"; } - - service.register(request.getName(), request.getBrand(), request.getCategory()); + service.register(request.getName(), request.getBrand(), request.getCategory(), + splitIngredients(request.getIngredientsText())); redirectAttributes.addFlashAttribute("successMessage", "제품을 등록했습니다."); return "redirect:/admin/products"; } + + private List splitIngredients(String text) { + if (text == null || text.isBlank()) return List.of(); + return Arrays.stream(text.split("[,\\n]")) + .map(String::trim).filter(value -> !value.isBlank()).distinct().toList(); + } + + /** 제품 상세. 파이프라인이 채운 값(카탈로그·언급·INCI API 원본)까지 전부 보여 준다. */ + @GetMapping("/admin/products/{id}") + public String detail(@PathVariable String id, Model model) { + var product = service.getProduct(id); + model.addAttribute("product", product); + model.addAttribute("inciapiRawJson", prettyJson(product.inciapiRawData())); + return "product-detail"; + } + + private String prettyJson(Map raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(raw); + } catch (RuntimeException e) { + // 원본을 못 읽는다고 상세 화면 전체가 죽으면 안 된다 — 사유만 남기고 나머지를 보여 준다. + return "원본 데이터를 표시할 수 없습니다: " + e.getMessage(); + } + } + + @PostMapping("/admin/products/{id}/delete") + public String delete(@PathVariable String id, RedirectAttributes redirectAttributes) { + var product = service.getProduct(id); + service.delete(id); + redirectAttributes.addFlashAttribute("successMessage", "'" + product.name() + "'을(를) 삭제했습니다."); + return "redirect:/admin/products"; + } + + /** + * 1단계 저장 — 전성분만. 저장 후에는 방금까지 보던 성분 보완 큐로 돌려보낸다(다음 건을 + * 이어서 처리하는 흐름). 목록 첫 화면으로 튕기면 처리하던 자리를 잃는다. + */ + @PostMapping("/admin/products/{id}/workflow/ingredients") + public String workflowIngredients(@PathVariable String id, @ModelAttribute ProductRegisterRequest request, + RedirectAttributes redirectAttributes) { + boolean ingredientNotFound = "NOT_FOUND".equals(request.getIngredientResolution()); + List ingredients = splitIngredients(request.getIngredientsText()); + if (ingredientNotFound && !ingredients.isEmpty()) { + redirectAttributes.addFlashAttribute("errorMessage", "성분을 찾지 못함을 선택한 경우 성분 입력란을 비워 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + if (!ingredientNotFound && ingredients.isEmpty()) { + redirectAttributes.addFlashAttribute("errorMessage", + "성분을 입력하거나, 찾지 못한 경우 '성분을 찾지 못함'을 선택해 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + service.reviewIngredients(id, ingredients, ingredientNotFound); + redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다."); + return "redirect:/admin/products?stage=ingredient-review"; + } + + /** 2단계 저장 — 식약처 기능성만. 저장 후 기능성 확인 큐로 돌아간다. */ + @PostMapping("/admin/products/{id}/workflow/functions") + public String workflowFunctions(@PathVariable String id, @ModelAttribute ProductRegisterRequest request, + RedirectAttributes redirectAttributes) { + String result = request.getFunctionResult(); + if (result == null || result.isBlank()) { + redirectAttributes.addFlashAttribute("errorMessage", "의약품안전나라 조회 결과를 선택해 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + boolean confirmed = "CONFIRMED".equals(result); + boolean hasFunction = !request.getFunction().isEmpty(); + if (confirmed && !hasFunction) { + redirectAttributes.addFlashAttribute("errorMessage", "기능성 확인을 선택한 경우 유형을 최소 1개 선택해 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + // '기능성 아님'을 고르고 유형을 남겨 두면 모순이므로 유형을 버린다. + service.reviewFunction(id, confirmed ? request.getFunction() : List.of()); + redirectAttributes.addFlashAttribute("successMessage", "기능성 검수 정보를 저장했습니다."); + return "redirect:/admin/products?stage=functional-review"; + } + + /** + * 검수 작업 화면. 어느 단계를 열지는 제품 상태가 정한다({@link ProductStatus#workflowStep()}). + * step 파라미터는 이미 지나간 단계를 다시 여는 용도다 — 기능성 화면에서 "성분 수정"으로 + * 넘어가거나, 파이프라인이 늦어 PENDING 제품에 성분을 직접 넣는 경우. + */ + @GetMapping("/admin/products/{id}/workflow") + public String workflowPage(@PathVariable String id, + @RequestParam(required = false) String step, + Model model) { + var product = service.getProduct(id); + ProductRegisterRequest request = new ProductRegisterRequest(); + request.setName(product.name()); + request.setBrand(product.brand()); + request.setCategory(product.category()); + request.setIngredientResolution(product.status() == ProductStatus.NOT_FOUND ? "NOT_FOUND" : "FOUND"); + request.setFunction(product.function().stream().map(Enum::name).toList()); + // 이미 검수한 제품만 현재 값을 찍어 준다. 미검수면 비워 둬서 어드민이 직접 고르게 한다. + if (product.status().functionalReviewDone()) { + request.setFunctionResult(product.hasFunction() ? "CONFIRMED" : "NONE"); + } + 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(); + if (resolved == null) { + // 파이프라인이 굴리는 중이거나 이미 끝난 제품은 어드민이 할 일이 없다 — 상세로 보낸다. + return "redirect:/admin/products/" + id; + } + model.addAttribute("workflowStep", resolved); + return "product-workflow"; + } } diff --git a/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java b/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java index 1df70bc..6ca2110 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 @@ -4,6 +4,15 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; + +/** + * 등록 폼과 검수 폼이 함께 쓰는 입력값. + * + *

기능성 "검수 여부"를 받는 필드는 없다 — 그건 status가 담고, 이 폼은 확인된 유형(function)만 + * 받는다. 유형이 비어 있으면 "확인했으나 기능성 아님"이다. + */ public class ProductRegisterRequest { @NotBlank(message = "제품명을 입력해 주세요.") @@ -21,27 +30,38 @@ public class ProductRegisterRequest { ) private String category; - public String getName() { - return name; - } + /** 식약처 기능성 유형. REVIEWED_NONE 같은 별도 상태값 없이 이 목록의 유무가 결과를 말한다. */ + private List function = new ArrayList<>(); + + /** + * 2단계 검수 화면의 라디오. NONE(기능성 아님) 또는 CONFIRMED(기능성 확인). + * + *

기본값을 두지 않는다. 미검수 제품에 "기능성 아님"이 미리 찍혀 있으면 확인 없이 저장만 + * 눌러도 식약처 기능성 아님이 사실로 기록된다 — 규제 정보라 기본값으로 정할 수 없다. + */ + private String functionResult; + + private String ingredientResolution = "FOUND"; + private String ingredientsText; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } - public void setName(String name) { - this.name = name; - } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } - public String getBrand() { - return brand; - } + public List getFunction() { return function; } + public void setFunction(List value) { this.function = value == null ? new ArrayList<>() : value; } - public void setBrand(String brand) { - this.brand = brand; - } + public String getFunctionResult() { return functionResult; } + public void setFunctionResult(String value) { this.functionResult = value; } - public String getCategory() { - return category; - } + public String getIngredientResolution() { return ingredientResolution; } + public void setIngredientResolution(String value) { this.ingredientResolution = value; } - public void setCategory(String category) { - this.category = category; - } + public String getIngredientsText() { return ingredientsText; } + public void setIngredientsText(String value) { this.ingredientsText = value; } } 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 a961b26..4d9c1be 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 @@ -53,6 +53,7 @@ public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQue if (bindingResult.hasErrors()) { model.addAttribute("request", new SurveyOptionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("questionFormOpen", true); return "survey"; } @@ -62,6 +63,7 @@ public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQue bindingResult.rejectValue("questionKey", "invalid", e.getMessage()); model.addAttribute("request", new SurveyOptionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("questionFormOpen", true); return "survey"; } redirectAttributes.addFlashAttribute("successMessage", "문항을 추가했습니다."); @@ -76,6 +78,7 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR if (bindingResult.hasErrors()) { model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("optionFormOpen", true); return "survey"; } @@ -87,6 +90,7 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR bindingResult.rejectValue("code", "invalid", e.getMessage()); model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("optionFormOpen", true); return "survey"; } redirectAttributes.addFlashAttribute("successMessage", "선택지를 추가했습니다."); diff --git a/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java b/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java index 5684e1a..63cbf72 100644 --- a/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java +++ b/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java @@ -39,6 +39,7 @@ public String register( ) { if (bindingResult.hasErrors()) { model.addAttribute("videos", service.getVideos()); + model.addAttribute("registerFormOpen", true); return "videos"; } @@ -49,6 +50,7 @@ public String register( } catch (YoutubeAdminException e) { bindingResult.rejectValue("url", e.reason().name(), e.reason().userMessage()); model.addAttribute("videos", service.getVideos()); + model.addAttribute("registerFormOpen", true); return "videos"; } } diff --git a/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java b/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java index 218676c..7068a2e 100644 --- a/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java +++ b/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java @@ -39,6 +39,7 @@ public String register( ) { if (bindingResult.hasErrors()) { model.addAttribute("youtubers", service.getYoutubers()); + model.addAttribute("registerFormOpen", true); return "youtubers"; } @@ -49,6 +50,7 @@ public String register( } catch (YoutubeAdminException e) { bindingResult.rejectValue("url", e.reason().name(), e.reason().userMessage()); model.addAttribute("youtubers", service.getYoutubers()); + model.addAttribute("registerFormOpen", true); return "youtubers"; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c7c714f..418a98b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,16 +3,15 @@ spring: name: seoulection-admin mongodb: uri: ${MONGODB_URI:mongodb://localhost:27017/seoulection} - # 설문 문항·선택지 마스터(survey_question / survey_option)만 Postgres에 있다. - # 나머지 관리 대상(youtubers·videos·products)은 그대로 Mongo다 — 데이터소스가 둘인 이유. + # 설문 문항·선택지와 성분 카탈로그를 Postgres에 둔다. + # 나머지 관리 대상(youtubers·videos·products)은 Mongo다 — 데이터소스가 둘인 이유. datasource: url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5433/seoulection} username: ${SPRING_DATASOURCE_USERNAME:postgres} password: ${SPRING_DATASOURCE_PASSWORD:} jpa: hibernate: - # ⚠️ none이어야 한다. 이 테이블의 스키마 주인은 api-server다(그쪽이 ddl-auto=update). - # 여기서 update를 켜면 두 앱이 같은 테이블 DDL을 서로 밀어내며 경합한다. + # ⚠️ none이어야 한다. 스키마 주인은 api-server의 Flyway migration이다. ddl-auto: none open-in-view: false thymeleaf: diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index e9acc41..ac51a2b 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -156,7 +156,14 @@ a { color: inherit; } border-bottom: 1px solid var(--border); } +.topbar { + position: sticky; + top: 0; + z-index: 10; +} + .topbar h1 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; } +.topbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .topbar .description { margin-top: 3px; font-size: 13.5px; color: var(--text-secondary); } .content { @@ -165,6 +172,22 @@ a { color: inherit; } padding: 32px 36px 56px; } +/* 폼 한 개만 있는 상세/검수 화면은 좁게 — 넓은 표와 같은 폭이면 필드가 늘어져 읽기 나쁘다. */ +.content-narrow { width: min(760px, 100%); } + +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 600; +} + +.breadcrumb a { color: var(--text-secondary); text-decoration: none; } +.breadcrumb a:hover { color: var(--accent); } + /* ── Alerts ── */ .alert { @@ -204,11 +227,12 @@ a { color: inherit; } } .card.is-inactive { background: var(--surface-sunken); } -.card.is-inactive > h2, .card.is-inactive > .title-form { opacity: .55; } +.card.is-inactive > h2, .card.is-inactive > .panel-header .title-form { opacity: .55; } /* ── Forms ── */ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.form-grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .span-full { grid-column: 1 / -1; } @@ -240,6 +264,500 @@ input:focus-visible { } input::placeholder { color: var(--text-tertiary); } +input:disabled, textarea:disabled { background: var(--surface-sunken); color: var(--text-tertiary); cursor: not-allowed; } + +textarea { + width: 100%; + padding: 12px 13px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font: inherit; + color: var(--text-primary); + background: var(--surface); + resize: vertical; +} + +textarea: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 { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.section-heading-row label { margin-bottom: 0; } +.section-heading-row .form-help { margin: 5px 0 0; } +.detail-table { min-width: 760px; table-layout: auto; } +.detail-table th, .detail-table td { padding-top: 12px; padding-bottom: 12px; line-height: 1.5; white-space: normal; word-break: keep-all; } +.detail-table td small { display: block; margin-top: 4px; color: var(--text-secondary); font-size: 11px; } +.detail-table th:nth-child(1) { width: 23%; } +.detail-table th:nth-child(2) { width: 15%; } +.detail-table th:nth-child(3) { width: 17%; } +.detail-table th:nth-child(4) { width: 20%; } +.efficacy-table { min-width: 1180px; } +.efficacy-table th:nth-child(1) { width: 16%; } +.efficacy-table th:nth-child(2) { width: 14%; } +.efficacy-table th:nth-child(3) { width: 18%; } +.efficacy-table th:nth-child(4), .efficacy-table th:nth-child(5) { width: 13%; } +.efficacy-table th:nth-child(6) { width: 12%; } +.empty-state { padding: 18px 0; color: var(--text-secondary); font-size: 13px; } +.condition-list { display: grid; gap: 10px; } +.condition-item { display: block; font-size: 12px; line-height: 1.45; } +.condition-item .data-chip { display: inline-flex; margin-bottom: 4px; } +.condition-item > span:not(.data-chip) { display: block; } +.condition-item small { width: 100%; margin: 0 !important; font-size: 10.5px !important; } +.advanced-editor { margin-top: 8px; } +.advanced-editor details { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-sunken); } +.advanced-editor summary { padding: 12px 14px; cursor: pointer; color: var(--text-secondary); font-size: 13px; font-weight: 700; } +.advanced-editor-body { display: grid; gap: 8px; padding: 0 14px 14px; } +.advanced-editor-body label { margin-top: 8px; } +.inline-review-form { min-width: 320px; padding: 12px 0; } +.inline-review-form label { margin-top: 9px; } +.text-link { color: var(--accent); font-size: 12px; font-weight: 700; text-decoration: none; } +.text-link:hover { text-decoration: underline; } +.form-actions { display: flex; gap: 10px; align-items: center; margin-top: 20px; } +.button-secondary { display: inline-flex; align-items: center; height: 40px; margin: 0; padding: 0 14px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-secondary); text-decoration: none; font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; } +.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; } +.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; } +.chip-list { display: flex; flex-wrap: wrap; gap: 5px; } +.data-chip { display: inline-flex; align-items: center; padding: 4px 7px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; } +.data-chip.is-neutral { background: var(--neutral-soft); color: var(--text-secondary); } +.data-chip.is-effect { background: var(--success-soft); color: var(--success); } +.data-chip.is-core { background: #e8f6ee; color: #087443; } +.data-chip.is-support { background: #f1f3f5; color: #667085; } +/* ── Data tables ── */ + +.table-scroll { overflow-x: auto; } +.admin-table { table-layout: fixed; } +.admin-table td { vertical-align: top; } +.admin-table th { position: sticky; top: 0; z-index: 1; background: var(--surface-sunken); white-space: nowrap; } +.admin-table thead th { border-bottom: 1px solid var(--border); } +.admin-table th, .admin-table td { padding-left: 24px; padding-right: 14px; } +.admin-table th:first-child, .admin-table td:first-child { padding-left: 24px; } +.admin-table th:last-child, .admin-table td:last-child { padding-right: 24px; } +.admin-table .cell-strong { font-weight: 600; } +.admin-table .cell-strong a { color: var(--text-primary); font-weight: 600; text-decoration: none; } +.admin-table .cell-strong a:hover { color: var(--accent); text-decoration: underline; } +.admin-table .is-numeric { text-align: right; font-variant-numeric: tabular-nums; } +.admin-table th:last-child, .admin-table td.admin-actions { width: 132px; min-width: 132px; white-space: nowrap; } + +/* 긴 URL이 표를 밀지 않게 한 줄로 잘라 준다(table-layout: fixed 전제). */ +.cell-truncate { max-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.cell-nowrap { white-space: nowrap; } +.cell-truncate a { display: block; overflow: hidden; text-overflow: ellipsis; } + +.ingredient-table th:nth-child(1) { width: 17%; } +.ingredient-table th:nth-child(2) { width: 18%; } +.ingredient-table th:nth-child(3) { width: 22%; } +.ingredient-table th:nth-child(4) { width: auto; } +.admin-actions .text-link { display: block; width: max-content; min-width: 84px; white-space: nowrap !important; word-break: normal !important; overflow-wrap: normal !important; } +.product-table th:nth-child(1) { width: 19%; } +.product-table th:nth-child(2) { width: 12%; } +.product-table th:nth-child(3) { width: 12%; } +.product-table th:nth-child(4) { width: 22%; } +.product-table th:nth-child(5) { width: 6%; } +.product-table th:nth-child(6) { width: 17%; } +.product-table th:last-child { width: 120px; } +.product-table .is-functional { background: var(--accent-soft); color: var(--accent); } + +/* ── Toolbar: 상태 탭 + 화면 액션 ── + 탭 배지가 이 화면의 요약 숫자를 겸한다. 별도 통계 카드 줄을 두면 같은 값이 두 번 나온다. */ + +.toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; } + +.tabs-group-label { + padding: 0 8px 0 2px; + color: var(--text-tertiary); + font-size: 10.5px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + white-space: nowrap; +} + +.tabs-divider { width: 1px; height: 16px; margin: 0 8px; background: var(--border); } + +/* ── 툴바 검색 ── */ + +/* 표 카드 헤더 오른쪽에 붙는다. 탭 줄에 두면 탭이 밀려 두 줄이 된다. */ +.toolbar-search { + position: relative; + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.toolbar-search input[type=search] { + width: 240px; + height: 36px; + padding: 0 12px 0 34px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-primary); + font-family: inherit; + font-size: 13.5px; +} + +.toolbar-search input[type=search]:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft-strong); +} + +.search-icon { + position: absolute; + left: 11px; + display: grid; + place-items: center; + width: 15px; + height: 15px; + color: var(--text-tertiary); + pointer-events: none; +} + +.search-icon svg { width: 100%; height: 100%; } + +.tabs a { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 11px 12px; + margin-bottom: -1px; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-size: 13.5px; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + transition: color .12s, border-color .12s; +} + +.tabs a:hover { color: var(--text-primary); } +.tabs a.is-active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 700; } + +.tabs .count { + min-width: 20px; + padding: 2px 6px; + border-radius: 999px; + background: var(--neutral-soft); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + line-height: 1.45; + text-align: center; +} + +.tabs a.is-active .count { background: var(--accent-soft); color: var(--accent); } +.tabs .count.is-alert { background: var(--danger-soft); color: var(--danger); } +.tabs .count.is-attention { background: var(--warning-soft); color: var(--warning); } + +/* ── 카드 헤더 / 접히는 폼 패널 ── */ + +.card.is-flush { padding: 0; overflow: hidden; } + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 24px; + border-bottom: 1px solid var(--border); +} + +.panel-header h2 { display: flex; align-items: center; gap: 8px; font-size: 15.5px; font-weight: 700; } +.panel-header .card-subtitle { margin: 4px 0 0; } +.panel-header-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.panel-header .title-form { flex: 1; min-width: 0; margin: 0; } + +.count-inline { + padding: 2px 8px; + border-radius: 999px; + background: var(--neutral-soft); + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 700; +} + +/* 탭 안의 상태별 소계. 누르면 그 상태만 걸리고, 다시 누르면 해제된다. */ +.substat { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } + +.substat a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; + transition: border-color .12s, background .12s, color .12s; +} + +.substat a:hover { border-color: var(--border-strong); color: var(--text-primary); } +.substat a.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 700; } +.substat b { color: var(--text-primary); font-variant-numeric: tabular-nums; } +.substat a.is-active b { color: var(--accent); } + +/* 검수 화면 단계 표시 — 지금 어느 단계인지, 몇 단계짜리인지 한눈에. */ +.step-rail { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; padding: 0; list-style: none; } + +.step-rail li { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-tertiary); + font-size: 13px; + font-weight: 600; +} + +.step-rail li + li::before { content: ''; width: 20px; height: 1px; margin-right: 3px; background: var(--border-strong); } +.step-rail li span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 999px; background: var(--neutral-soft); color: var(--text-tertiary); font-size: 11.5px; font-weight: 700; } +.step-rail li.is-current { color: var(--accent); } +.step-rail li.is-current span { background: var(--accent); color: white; } +.step-rail li.is-done { color: var(--text-secondary); } +.step-rail li.is-done span { background: var(--success-soft); color: var(--success); } + +/* 다음 단계에서 앞 단계 결과를 근거로만 보여 주는 영역. */ +.readonly-section { background: var(--surface-sunken); } +.readonly-section .form-section-title { margin-bottom: 10px; } +.readonly-section .form-section-title .text-link { margin-left: auto; letter-spacing: 0; text-transform: none; } + +/* ── Drawer: 화면 오른쪽에서 밀려 나오는 등록·편집 패널 ── + 등록 폼이 800px를 넘어 모달에 넣으면 잘린다(노트북 뷰포트가 약 760px). 드로어는 화면 높이를 + 그대로 쓰므로 넘쳐도 위→아래 한 방향 스크롤이고, 목록이 왼쪽에 남아 대조할 수 있다. */ + +.drawer-scrim { + position: fixed; + inset: 0; + z-index: 90; + /* 목록이 비쳐 보일 정도로만 덮는다 — 가리는 게 목적이 아니라 "지금은 이 작업 중"을 알리는 것. */ + background: rgba(20, 23, 31, .32); + opacity: 0; + visibility: hidden; + transition: opacity .22s ease, visibility .22s ease; +} + +.drawer-scrim.is-open { opacity: 1; visibility: visible; } + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + width: min(600px, 100%); + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: -14px 0 36px rgba(20, 23, 31, .12); + transform: translateX(100%); + visibility: hidden; + transition: transform .22s ease, visibility .22s ease; +} + +.drawer.is-open { transform: translateX(0); visibility: visible; } + +body.is-drawer-open { overflow: hidden; } + +@media (prefers-reduced-motion: reduce) { + .drawer, .drawer-scrim { transition: none; } +} + +.drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} + +.drawer-header h2 { font-size: 16px; font-weight: 700; } +.drawer-header .card-subtitle { margin: 4px 0 0; } + +/* 헤더와 푸터는 붙박이, 가운데 본문만 스크롤한다 — 저장 버튼이 스크롤에 묻히지 않게. */ +.drawer form { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.drawer-body { flex: 1; min-height: 0; overflow-y: auto; } +.drawer .form-section:last-child { border-bottom: 0; } +.drawer .form-footer { flex-shrink: 0; border-top: 1px solid var(--border); } + +.form-section { padding: 20px 24px; border-bottom: 1px solid var(--border); } + +.form-section-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + color: var(--text-tertiary); + font-size: 11.5px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.form-section-title code { text-transform: none; letter-spacing: 0; } + +.required-mark, .optional-mark { + padding: 2px 6px; + border-radius: 4px; + font-size: 10.5px; + letter-spacing: 0; +} + +.required-mark { background: var(--accent-soft); color: var(--accent); } +.optional-mark { background: var(--neutral-soft); color: var(--text-tertiary); } + +.form-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + background: var(--surface-sunken); +} + +.option-table th:nth-child(1) { width: 22%; } +.option-table th:nth-child(2) { width: auto; } +.option-table th:nth-child(3) { width: 90px; } +.option-form { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.option-form input[type=text] { flex: 1; min-width: 180px; } + +/* ── 상세 화면 ── */ + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 4px 0; + margin: 0; + padding: 6px 0 14px; +} + +.detail-grid > div { padding: 16px 24px; } +.detail-grid dt { margin-bottom: 6px; color: var(--text-tertiary); font-size: 11.5px; font-weight: 700; letter-spacing: .03em; } +.detail-grid dt code { padding: 1px 5px; font-size: 10.5px; font-weight: 500; } +.detail-grid dd { margin: 0; font-size: 13.5px; color: var(--text-primary); } +.detail-grid dd.is-numeric { font-variant-numeric: tabular-nums; } + +.code-block { + max-height: 320px; + margin: 0; + padding: 14px 16px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-sunken); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; + white-space: pre; +} + +/* ── 페이지네이션 ── */ + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 24px; + border-top: 1px solid var(--border); + background: var(--surface-sunken); +} + +.pagination-range { color: var(--text-secondary); font-size: 12.5px; font-variant-numeric: tabular-nums; } +.pagination-buttons { display: flex; align-items: center; gap: 6px; } +.pagination-page { padding: 0 8px; color: var(--text-secondary); font-size: 12.5px; font-variant-numeric: tabular-nums; } + +.pagination a, .pagination .is-disabled { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface); + font-size: 12.5px; + font-weight: 600; + text-decoration: none; +} + +.pagination a { color: var(--text-primary); } +.pagination a:hover { border-color: var(--accent); color: var(--accent); } +.pagination .is-disabled { color: var(--text-tertiary); opacity: .55; } + +.thumb { width: 56px; height: 56px; border-radius: 6px; border: 1px solid var(--border); object-fit: cover; } +.detail-grid .span-full { grid-column: 1 / -1; } +.dd-wrap { overflow-wrap: anywhere; } + +.danger-zone { border-color: #f2c6c6; } +.danger-zone .panel-header { align-items: center; border-bottom: 0; } +.danger-zone form { flex-shrink: 0; } + +/* ── 검수 화면의 단계 구분 ── */ + +.review-step { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.step-label { + color: var(--text-tertiary); + font-size: 11.5px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.step-number { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: var(--accent); + color: white; + font-size: 11.5px; + font-weight: 700; + flex-shrink: 0; +} + +.step-status { + margin-left: auto; + padding: 3px 9px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11.5px; + font-weight: 700; +} + +/* review-step은 form-section 맨 위에 오므로 아래 필드와 간격을 준다. */ +.form-section > .review-step { margin-bottom: 18px; } .form-row { display: flex; gap: 10px; } .form-row input { flex: 1; } @@ -251,9 +769,16 @@ input::placeholder { color: var(--text-tertiary); } align-items: center; } +.policy-table th:nth-child(1) { width: 26%; } + +/* td input[type=text]의 min-width: 240px가 그리드 트랙을 밀어 저장 버튼과 겹쳤다. */ +.policy-form input[type=text] { min-width: 0; } + .toggle-group { display: flex; flex-wrap: wrap; gap: 8px; } -.toggle-option { margin: 0; cursor: pointer; } -.toggle-option input { position: absolute; width: 1px; height: 1px; opacity: 0; } +/* position: relative가 없으면 숨긴 input이 먼 조상 기준으로 쌓여, 브라우저 기본 검증 + 말풍선이 화면 엉뚱한 곳에 뜬다. 라벨 기준으로 붙여 칩 옆에 나오게 한다. */ +.toggle-option { position: relative; margin: 0; cursor: pointer; } +.toggle-option input { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; opacity: 0; } .toggle-option span { display: inline-block; @@ -278,6 +803,16 @@ input::placeholder { color: var(--text-tertiary); } outline-offset: 2px; } +.toggle-option input:disabled + span { + background: var(--surface-sunken); + border-color: var(--border); + color: var(--text-tertiary); + cursor: not-allowed; +} +.property-toggle-group { margin-top: 8px; } +.property-value-list { display: grid; gap: 8px; margin-top: 10px; } +.property-value-list input { max-width: 420px; } + .error { margin: 7px 0 0; color: var(--danger); font-size: 13px; } /* ── Buttons ── */ @@ -299,6 +834,44 @@ button, .btn { button:hover { background: var(--accent-hover); } +.btn-primary { + display: inline-flex; + align-items: center; + gap: 6px; + height: 40px; + margin: 0; + padding: 0 15px; + border: 0; + border-radius: var(--radius-sm); + background: var(--accent); + color: white; + font-family: inherit; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; +} + +.btn-primary:hover { background: var(--accent-hover); } +a.btn-primary { text-decoration: none; } +.btn-primary[aria-expanded="true"] { background: var(--accent-hover); box-shadow: inset 0 2px 5px rgba(0, 0, 0, .18); } +.btn-icon { font-size: 15px; line-height: 1; } + +.btn-ghost { + height: 32px; + margin: 0; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.btn-ghost:hover { background: var(--neutral-soft); color: var(--text-primary); } + button.subtle { height: 32px; margin: 0; @@ -313,6 +886,8 @@ button.subtle:hover { background: var(--border); } button.danger { height: 32px; margin: 0; padding: 0 12px; background: var(--danger-soft); color: var(--danger); font-size: 12.5px; font-weight: 600; } button.danger:hover { background: #fbdada; } +.definition-actions { width: 108px !important; min-width: 108px !important; white-space: nowrap; } +.definition-actions form { display: flex; justify-content: flex-start; } /* ── Tables ── */ @@ -366,12 +941,24 @@ td a:hover { text-decoration: underline; } border-radius: 999px; font-size: 11.5px; font-weight: 700; + white-space: nowrap; } .badge-accent { background: var(--accent-soft); color: var(--accent); } .badge-success { background: var(--success-soft); color: var(--success); } .badge-warning { background: var(--warning-soft); color: var(--warning); } .badge-neutral { background: var(--neutral-soft); color: var(--text-secondary); } +.badge-danger { background: var(--danger-soft); color: var(--danger); } + +.badge.is-status::before { + content: ''; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 999px; + background: currentColor; + flex-shrink: 0; +} /* ── Empty state ── */ @@ -400,18 +987,16 @@ td a:hover { text-decoration: underline; } /* ── Home: quick-link grid ── */ -.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 14px; margin-bottom: 28px; } - -.stat-card { - padding: 18px 20px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-md); - box-shadow: var(--shadow-sm); -} - -.stat-card .stat-label { font-size: 12px; color: var(--text-secondary); font-weight: 600; } -.stat-card .stat-value { margin-top: 6px; font-size: 26px; font-weight: 700; letter-spacing: -.02em; } +.choice-grid { display: grid; grid-template-columns: repeat(3, minmax(150px, 1fr)); gap: 10px; } +.choice-card { margin: 0; cursor: pointer; } +.choice-card input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.choice-card span { display: block; padding: 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); color: var(--text-secondary); transition: border-color .12s, background .12s, color .12s; } +.choice-card strong { display: block; color: var(--text-primary); font-size: 13px; } +.choice-card small { display: block; margin-top: 3px; color: var(--text-tertiary); font-size: 11px; } +.choice-card input:checked + span { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } +.choice-card input:checked + span strong { color: var(--accent); } +.choice-card input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } +@media (max-width: 600px) { .choice-grid { grid-template-columns: 1fr 1fr; } } .menu { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; } diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js new file mode 100644 index 0000000..434c585 --- /dev/null +++ b/src/main/resources/static/js/admin.js @@ -0,0 +1,80 @@ +/* + * MySeoulection Admin — 등록·편집 드로어. + * + * 등록 폼은 화면 오른쪽에서 밀려 나오는 드로어로 연다. 목록 사이에 끼워 넣으면(예전 방식) 표가 + * 아래로 밀려나고, 모달에 넣으면 800px가 넘는 폼이 잘린다. 드로어는 화면 높이를 그대로 쓰면서 + * 목록을 왼쪽에 남겨 둔다. + * + * 서버가 검증 실패로 화면을 다시 그릴 때는 템플릿이 미리 .is-open을 붙여 보낸다 — + * 그 경우 여기서는 스크림·포커스만 맞춰 주면 된다. + */ +(() => { + const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]),' + + ' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + const scrim = document.querySelector('[data-drawer-scrim]'); + const shell = document.querySelector('.shell'); + let openDrawer = null; + let lastTrigger = null; + + const togglesFor = (id) => document.querySelectorAll(`[data-panel-toggle="${id}"]`); + + function close({ restoreFocus = true } = {}) { + if (!openDrawer) return; + const drawer = openDrawer; + openDrawer = null; + drawer.classList.remove('is-open'); + scrim?.classList.remove('is-open'); + document.body.classList.remove('is-drawer-open'); + // 배경을 inert로 만들면 포커스도 클릭도 새지 않는다. 아래 Tab 가두기는 미지원 브라우저용 보완. + shell?.removeAttribute('inert'); + togglesFor(drawer.id).forEach(button => button.setAttribute('aria-expanded', 'false')); + if (restoreFocus) lastTrigger?.focus(); + } + + function open(drawer, trigger) { + if (openDrawer && openDrawer !== drawer) close({ restoreFocus: false }); + openDrawer = drawer; + lastTrigger = trigger ?? null; + drawer.classList.add('is-open'); + scrim?.classList.add('is-open'); + document.body.classList.add('is-drawer-open'); + shell?.setAttribute('inert', ''); + togglesFor(drawer.id).forEach(button => button.setAttribute('aria-expanded', 'true')); + (drawer.querySelector('input:not([type=hidden]):not([disabled]), textarea') || drawer).focus(); + } + + document.querySelectorAll('[data-panel-toggle]').forEach(button => { + button.addEventListener('click', () => { + const drawer = document.getElementById(button.dataset.panelToggle); + if (!drawer) return; + if (drawer.classList.contains('is-open')) close(); else open(drawer, button); + }); + }); + + scrim?.addEventListener('click', () => close()); + + document.addEventListener('keydown', (event) => { + if (!openDrawer) return; + if (event.key === 'Escape') { + close(); + return; + } + if (event.key !== 'Tab') return; + const items = [...openDrawer.querySelectorAll(FOCUSABLE)]; + if (!items.length) return; + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); + + // 서버가 열린 채로 내려보낸 드로어(검증 실패)의 나머지 상태를 맞춘다. + const serverOpened = document.querySelector('.drawer.is-open'); + if (serverOpened) open(serverOpened, null); +})(); diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/fragments/sidebar.html index 196baee..d379d0e 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/fragments/sidebar.html @@ -6,7 +6,7 @@ 메뉴 항목이 늘어나도 이 파일만 고치면 된다. 사용법:

- active 값은 home / youtubers / videos / products / survey 중 하나. + active 값은 home / youtubers / videos / products / ingredients / survey 중 하나. -->