Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.seoulection.admin.skinpolicy.application;

import com.seoulection.admin.skinpolicy.domain.SkinScorePolicy;
import com.seoulection.admin.skinpolicy.infrastructure.SkinScorePolicyJpaEntity;
import com.seoulection.admin.skinpolicy.infrastructure.SkinScorePolicyJpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class SkinScorePolicyService {
private final SkinScorePolicyJpaRepository repository;

public SkinScorePolicyService(SkinScorePolicyJpaRepository repository) {
this.repository = repository;
}

@Transactional(readOnly = true)
public List<SkinScorePolicy> findAll() {
return repository.findAll().stream().map(SkinScorePolicyJpaEntity::toDomain)
.sorted(java.util.Comparator.comparing(SkinScorePolicy::featureKey)).toList();
}

@Transactional
public void update(String featureKey, double badBoundary, double goodBoundary, String policyVersion) {
SkinScorePolicy current = repository.findById(featureKey)
.orElseThrow(() -> new IllegalArgumentException("없는 피부 항목입니다: " + featureKey))
.toDomain();
repository.save(SkinScorePolicyJpaEntity.from(new SkinScorePolicy(featureKey, current.label(),
current.surveyReliability(), current.photoReliability(), current.inconsistentSurveyFactor(),
badBoundary, goodBoundary, policyVersion.trim())));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.seoulection.admin.skinpolicy.domain;

public record SkinScorePolicy(String featureKey, String label, double surveyReliability,
double photoReliability, double inconsistentSurveyFactor,
double badBoundary, double goodBoundary,
String policyVersion) {
public SkinScorePolicy {
validate(surveyReliability, "설문 신뢰도");
validate(photoReliability, "사진 신뢰도");
validate(inconsistentSurveyFactor, "불일치 계수");
validate(badBoundary, "Bad 경계");
validate(goodBoundary, "Good 경계");
if (badBoundary > goodBoundary) {
throw new IllegalArgumentException("Bad 경계는 Good 경계보다 클 수 없습니다.");
}
if (surveyReliability + photoReliability <= 0) {
throw new IllegalArgumentException("설문·사진 신뢰도가 모두 0일 수 없습니다.");
}
if (policyVersion == null || policyVersion.isBlank()) {
throw new IllegalArgumentException("정책 버전은 비워둘 수 없습니다.");
}
}

private static void validate(double value, String name) {
if (value < 0 || value > 1) throw new IllegalArgumentException(name + "는 0~1이어야 합니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.seoulection.admin.skinpolicy.infrastructure;

import com.seoulection.admin.skinpolicy.domain.SkinScorePolicy;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "skin_score_policy")
public class SkinScorePolicyJpaEntity {
@Id @Column(name = "feature_key", length = 64)
private String featureKey;
@Column(nullable = false, length = 64)
private String label;
@Column(name = "survey_reliability", nullable = false)
private double surveyReliability;
@Column(name = "photo_reliability", nullable = false)
private double photoReliability;
@Column(name = "inconsistent_survey_factor", nullable = false)
private double inconsistentSurveyFactor;
@Column(name = "bad_boundary", nullable = false)
private double badBoundary;
@Column(name = "good_boundary", nullable = false)
private double goodBoundary;
@Column(name = "policy_version", nullable = false, length = 32)
private String policyVersion;

protected SkinScorePolicyJpaEntity() {}

private SkinScorePolicyJpaEntity(SkinScorePolicy policy) {
this.featureKey = policy.featureKey();
this.label = policy.label();
this.surveyReliability = policy.surveyReliability();
this.photoReliability = policy.photoReliability();
this.inconsistentSurveyFactor = policy.inconsistentSurveyFactor();
this.badBoundary = policy.badBoundary();
this.goodBoundary = policy.goodBoundary();
this.policyVersion = policy.policyVersion();
}

public static SkinScorePolicyJpaEntity from(SkinScorePolicy policy) {
return new SkinScorePolicyJpaEntity(policy);
}

public SkinScorePolicy toDomain() {
return new SkinScorePolicy(featureKey, label, surveyReliability, photoReliability,
inconsistentSurveyFactor, badBoundary, goodBoundary, policyVersion);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.seoulection.admin.skinpolicy.infrastructure;

import org.springframework.data.jpa.repository.JpaRepository;

public interface SkinScorePolicyJpaRepository extends JpaRepository<SkinScorePolicyJpaEntity, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.seoulection.admin.skinpolicy.presentation;

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

@Controller
public class SkinScorePolicyController {
private final SkinScorePolicyService service;

public SkinScorePolicyController(SkinScorePolicyService service) {
this.service = service;
}

@GetMapping("/admin/skin-score-policy")
public String page(Model model) {
model.addAttribute("policies", service.findAll());
return "skin-score-policy";
}

@PostMapping("/admin/skin-score-policy/{featureKey}")
public String update(@PathVariable String featureKey,
@RequestParam double badBoundary,
@RequestParam double goodBoundary,
@RequestParam String policyVersion,
RedirectAttributes redirectAttributes) {
try {
service.update(featureKey, badBoundary, goodBoundary, policyVersion);
redirectAttributes.addFlashAttribute("successMessage", featureKey + " 정책을 저장했습니다.");
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
}
return "redirect:/admin/skin-score-policy";
}
}
10 changes: 9 additions & 1 deletion src/main/resources/static/css/admin.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Seoulection Admin — 공용 디자인 시스템.
* MySeoulection Admin — 공용 디자인 시스템.
*
* 이전엔 페이지마다(products.html, videos.html, ...) <style> 블록을 통째로 복붙해서 색상·간격이
* 조금씩 어긋났다. 여기 한 곳에 토큰과 컴포넌트를 모아 모든 화면이 같은 값을 쓰게 한다.
Expand Down Expand Up @@ -177,6 +177,7 @@ a { color: inherit; }
}

.alert-success { background: var(--success-soft); color: var(--success); }
.alert-error { background: var(--danger-soft); color: var(--danger); }
.alert-notice { background: var(--warning-soft); color: var(--warning); }

/* ── Cards ── */
Expand Down Expand Up @@ -243,6 +244,13 @@ input::placeholder { color: var(--text-tertiary); }
.form-row { display: flex; gap: 10px; }
.form-row input { flex: 1; }

.policy-form {
display: grid;
grid-template-columns: minmax(90px, 1fr) repeat(2, minmax(80px, 1fr)) minmax(110px, 1fr) auto;
gap: 8px;
align-items: center;
}

.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; }
Expand Down
10 changes: 8 additions & 2 deletions src/main/resources/templates/fragments/sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<div class="brand">
<span class="brand-mark">S</span>
<div class="brand-text">
<strong>Seoulection</strong>
<strong>MySeoulection</strong>
<span>Admin</span>
</div>
</div>
Expand Down Expand Up @@ -49,9 +49,15 @@
</span>
<span>설문</span>
</a>
<a class="nav-item" th:classappend="${active == 'skin-score-policy'} ? 'is-active' : ''" th:href="@{/admin/skin-score-policy}">
<span class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M7 12h10M10 18h4"/><circle cx="9" cy="6" r="2" fill="currentColor"/><circle cx="15" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="18" r="2" fill="currentColor"/></svg>
</span>
<span>피부 점수 정책</span>
</a>
</nav>

<div class="sidebar-footer">Seoulection Admin</div>
<div class="sidebar-footer">MySeoulection Admin</div>
</aside>
</body>
</html>
2 changes: 1 addition & 1 deletion src/main/resources/templates/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Seoulection Admin</title>
<title>MySeoulection Admin</title>
<link rel="stylesheet" th:href="@{/css/admin.css}">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/templates/products.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>제품 관리 | Seoulection Admin</title>
<title>제품 관리 | MySeoulection Admin</title>
<link rel="stylesheet" th:href="@{/css/admin.css}">
</head>
<body>
Expand Down
46 changes: 46 additions & 0 deletions src/main/resources/templates/skin-score-policy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>피부 점수 정책 | MySeoulection Admin</title>
<link rel="stylesheet" th:href="@{/css/admin.css}">
</head>
<body>
<div class="shell">
<aside th:replace="~{fragments/sidebar :: nav(active='skin-score-policy')}"></aside>
<div class="shell-main">
<header class="topbar"><div><h1>피부 점수 정책</h1>
<p class="description">항목별 점수 원천과 피부 상태 판정 경계를 관리합니다.</p></div></header>
<main class="content">
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}"></div>
<div class="alert alert-error" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<p class="alert alert-notice">
CORE 항목은 설문 100%, 나머지 항목은 사진 100%로 고정됩니다.
점수가 Bad 경계 이하이면 BAD, Good 경계 이상이면 GOOD, 사이는 OKAY로 판정합니다.
경계 변경은 새 피부 결과 조회와 추천에 즉시 반영됩니다.
</p>
<section class="card">
<table>
<thead><tr><th>피부 항목</th><th>점수 원천</th><th>Bad 경계</th><th>Good 경계</th><th>정책 버전</th><th></th></tr></thead>
<tbody>
<tr th:each="policy : ${policies}">
<td><strong th:text="${policy.label}"></strong><br><code th:text="${policy.featureKey}"></code></td>
<td colspan="5">
<form class="policy-form" th:action="@{'/admin/skin-score-policy/' + ${policy.featureKey}}" method="post">
<strong th:text="${policy.surveyReliability > policy.photoReliability ? '설문 100%' : '사진 100%'}"></strong>
<input type="number" name="badBoundary" th:value="${policy.badBoundary}" min="0" max="1" step="0.001" required>
<input type="number" name="goodBoundary" th:value="${policy.goodBoundary}" min="0" max="1" step="0.001" required>
<input type="text" name="policyVersion" th:value="${policy.policyVersion}" maxlength="32" required>
<button class="subtle" type="submit">저장</button>
</form>
</td>
</tr>
</tbody>
</table>
</section>
</main>
</div>
</div>
</body>
</html>
2 changes: 1 addition & 1 deletion src/main/resources/templates/survey.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>설문 관리 | Seoulection Admin</title>
<title>설문 관리 | MySeoulection Admin</title>
<link rel="stylesheet" th:href="@{/css/admin.css}">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/templates/videos.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>YouTube 영상 관리 | Seoulection Admin</title>
<title>YouTube 영상 관리 | MySeoulection Admin</title>
<link rel="stylesheet" th:href="@{/css/admin.css}">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/templates/youtubers.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>YouTube 채널 관리 | Seoulection Admin</title>
<title>YouTube 채널 관리 | MySeoulection Admin</title>
<link rel="stylesheet" th:href="@{/css/admin.css}">
</head>
<body>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.seoulection.admin.skinpolicy.application;

import com.seoulection.admin.skinpolicy.domain.SkinScorePolicy;
import com.seoulection.admin.skinpolicy.infrastructure.SkinScorePolicyJpaEntity;
import com.seoulection.admin.skinpolicy.infrastructure.SkinScorePolicyJpaRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

@ExtendWith(MockitoExtension.class)
class SkinScorePolicyServiceTest {
@Mock SkinScorePolicyJpaRepository repository;
@InjectMocks SkinScorePolicyService service;

@Test
void updatePreservesFeatureSourceAndStoresBoundaries() {
var current = new SkinScorePolicy("water_score", "Hydration", 0.8, 0.2, 0.7, 0.398, 0.696, "v1");
given(repository.findById("water_score")).willReturn(
Optional.of(SkinScorePolicyJpaEntity.from(current)));

service.update("water_score", 0.4, 0.7, "skin-score-v2");

var captor = ArgumentCaptor.forClass(SkinScorePolicyJpaEntity.class);
then(repository).should().save(captor.capture());
assertThat(captor.getValue().toDomain()).isEqualTo(
new SkinScorePolicy("water_score", "Hydration", 0.8, 0.2, 0.7, 0.4, 0.7, "skin-score-v2"));
}

@Test
void updateRejectsBoundaryOutsideZeroToOne() {
var current = new SkinScorePolicy("water_score", "Hydration", 0.8, 0.2, 0.7, 0.398, 0.696, "v1");
given(repository.findById("water_score")).willReturn(
Optional.of(SkinScorePolicyJpaEntity.from(current)));

assertThatThrownBy(() -> service.update("water_score", 1.1, 0.7, "v2"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("0~1");
}
}