diff --git a/src/main/java/com/seoulection/admin/survey/application/dto/SurveyOptionResult.java b/src/main/java/com/seoulection/admin/survey/application/dto/SurveyOptionResult.java index f6a4ed7..409f585 100644 --- a/src/main/java/com/seoulection/admin/survey/application/dto/SurveyOptionResult.java +++ b/src/main/java/com/seoulection/admin/survey/application/dto/SurveyOptionResult.java @@ -7,13 +7,14 @@ public record SurveyOptionResult( Long id, String code, String label, + Integer value, int sortOrder, boolean exclusive, boolean active ) { public static SurveyOptionResult from(SurveyOption o) { - return new SurveyOptionResult(o.getId(), o.getCode(), o.getLabel(), o.getSortOrder(), + return new SurveyOptionResult(o.getId(), o.getCode(), o.getLabel(), o.getValue(), o.getSortOrder(), o.isExclusive(), o.isActive()); } } diff --git a/src/main/java/com/seoulection/admin/survey/application/dto/SurveyQuestionResult.java b/src/main/java/com/seoulection/admin/survey/application/dto/SurveyQuestionResult.java index 54029e7..82e808d 100644 --- a/src/main/java/com/seoulection/admin/survey/application/dto/SurveyQuestionResult.java +++ b/src/main/java/com/seoulection/admin/survey/application/dto/SurveyQuestionResult.java @@ -8,10 +8,12 @@ public record SurveyQuestionResult( String key, String title, + boolean active, List options ) { public static SurveyQuestionResult of(SurveyQuestion question, List options) { - return new SurveyQuestionResult(question.getQuestionKey().name(), question.getTitle(), options); + return new SurveyQuestionResult(question.getQuestionKey(), question.getTitle(), question.isActive(), + options); } } diff --git a/src/main/java/com/seoulection/admin/survey/application/service/SurveyAdminService.java b/src/main/java/com/seoulection/admin/survey/application/service/SurveyAdminService.java index 9ca83f1..a19d489 100644 --- a/src/main/java/com/seoulection/admin/survey/application/service/SurveyAdminService.java +++ b/src/main/java/com/seoulection/admin/survey/application/service/SurveyAdminService.java @@ -4,7 +4,6 @@ import com.seoulection.admin.survey.application.dto.SurveyQuestionResult; import com.seoulection.admin.survey.domain.entity.SurveyOption; import com.seoulection.admin.survey.domain.entity.SurveyQuestion; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import com.seoulection.admin.survey.domain.repository.SurveyOptionRepository; import com.seoulection.admin.survey.domain.repository.SurveyQuestionRepository; import org.springframework.stereotype.Service; @@ -37,25 +36,39 @@ public List getQuestions() { .toList(); } + /** + * 문항 추가. {@code questionKey}는 자연 PK라 유일해야 한다 — 중복을 그냥 저장하면 upsert라서 + * 기존 문항을 조용히 덮어써 버린다(제목·순서가 실수로 바뀐다). 그래서 여기서 먼저 걸러 안내한다. + */ + @Transactional + public void createQuestion(String questionKey, String title, int sortOrder) { + String normalizedKey = questionKey == null ? "" : questionKey.trim().toUpperCase(); + if (questionRepository.existsByKey(normalizedKey)) { + throw new IllegalArgumentException("이미 있는 문항입니다: " + normalizedKey); + } + questionRepository.save(SurveyQuestion.create(normalizedKey, title, sortOrder)); + } + /** * 선택지 추가. {@code code}는 문항 안에서 유일해야 한다 — 중복을 허용하면 사용자 응답이 어느 쪽을 * 가리키는지 알 수 없어진다(DB 유니크 제약도 있지만 여기서 먼저 걸러 안내 문구를 준다). */ @Transactional - public void createOption(SurveyQuestionKey questionKey, String code, String label, + public void createOption(String questionKey, String code, String label, Integer value, int sortOrder, boolean exclusive) { String normalizedCode = code == null ? "" : code.trim().toUpperCase(); if (optionRepository.existsByQuestionKeyAndCode(questionKey, normalizedCode)) { throw new IllegalArgumentException("이미 있는 코드입니다: " + normalizedCode); } - optionRepository.save(SurveyOption.create(questionKey, normalizedCode, label, sortOrder, exclusive)); + optionRepository.save( + SurveyOption.create(questionKey, normalizedCode, label, value, sortOrder, exclusive)); } - /** 문구·순서·단독선택 수정. {@code code}는 대상이 아니다(도메인이 막는다). */ + /** 문구·점수·순서·단독선택 수정. {@code code}는 대상이 아니다(도메인이 막는다). */ @Transactional - public void updateOption(Long optionId, String label, int sortOrder, boolean exclusive) { + public void updateOption(Long optionId, String label, Integer value, int sortOrder, boolean exclusive) { SurveyOption option = getOption(optionId); - optionRepository.save(option.withDetails(label, sortOrder, exclusive)); + optionRepository.save(option.withDetails(label, value, sortOrder, exclusive)); } /** @@ -69,13 +82,29 @@ public void changeOptionActive(Long optionId, boolean active) { } @Transactional - public void updateQuestionTitle(SurveyQuestionKey questionKey, String title) { - SurveyQuestion question = questionRepository.findByKey(questionKey) - .orElseThrow(() -> new IllegalArgumentException("없는 문항입니다: " + questionKey)); + public void updateQuestionTitle(String questionKey, String title) { + SurveyQuestion question = getQuestion(questionKey); questionRepository.save(question.withTitle(title)); } - private List findOptions(SurveyQuestionKey questionKey) { + /** + * 문항 노출/숨김 전환. 이것이 문항 삭제다 — 이미 이 문항으로 답한 응답이 있어, 행을 지우면 + * 그 응답이 가리키는 문항 문구를 되찾을 수 없다({@link #changeOptionActive} 참조). 되살리기도 같은 경로다. + * + *

숨겨도 이미 제출된 응답과 그 선택지들은 그대로 남는다 — {@code GET /survey/questions}에서만 빠진다. + */ + @Transactional + public void changeQuestionActive(String questionKey, boolean active) { + SurveyQuestion question = getQuestion(questionKey); + questionRepository.save(question.withActive(active)); + } + + private SurveyQuestion getQuestion(String questionKey) { + return questionRepository.findByKey(questionKey) + .orElseThrow(() -> new IllegalArgumentException("없는 문항입니다: " + questionKey)); + } + + private List findOptions(String questionKey) { return optionRepository.findByQuestionKey(questionKey).stream() .map(SurveyOptionResult::from) .toList(); diff --git a/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyOption.java b/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyOption.java index bb091a8..0c9298f 100644 --- a/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyOption.java +++ b/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyOption.java @@ -1,7 +1,5 @@ package com.seoulection.admin.survey.domain.entity; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; - import java.util.Objects; import java.util.regex.Pattern; @@ -9,7 +7,7 @@ * 설문 선택지 — 관리자가 CRUD하는 대상. * *

{@code code}는 생성 시 확정되고 이후 바뀌지 않는다. 사용자 응답이 - * {@code survey_response.avoidances} jsonb 배열에 이 문자열로 저장돼 있어서, 코드를 고치면 이미 제출된 + * {@code survey_answer.codes} jsonb 배열에 이 문자열로 저장돼 있어서, 코드를 고치면 이미 제출된 * 응답이 가리키는 대상이 사라진다. 그래서 수정 메서드({@link #withDetails})에 code가 없다 — * 화면에서 막는 게 아니라 타입에서 막는다. * @@ -21,44 +19,46 @@ public class SurveyOption { private static final Pattern CODE_FORMAT = Pattern.compile("^[A-Z][A-Z0-9_]*$"); private final Long id; - private final SurveyQuestionKey questionKey; + private final String questionKey; private final String code; private final String label; + private final Integer value; private final int sortOrder; private final boolean exclusive; private final boolean active; - private SurveyOption(Long id, SurveyQuestionKey questionKey, String code, String label, + private SurveyOption(Long id, String questionKey, String code, String label, Integer value, int sortOrder, boolean exclusive, boolean active) { this.id = id; this.questionKey = Objects.requireNonNull(questionKey, "questionKey"); this.code = requireCode(code); this.label = requireText(label, "label"); + this.value = requireValidValue(value); this.sortOrder = requireNonNegative(sortOrder); this.exclusive = exclusive; this.active = active; } - /** 새 선택지. 활성 상태로 시작한다. */ - public static SurveyOption create(SurveyQuestionKey questionKey, String code, String label, + /** 새 선택지. 활성 상태로 시작한다. {@code value}는 카테고리성 선택지(회피 항목 등)라면 null. */ + public static SurveyOption create(String questionKey, String code, String label, Integer value, int sortOrder, boolean exclusive) { - return new SurveyOption(null, questionKey, code, label, sortOrder, exclusive, true); + return new SurveyOption(null, questionKey, code, label, value, sortOrder, exclusive, true); } /** 저장소가 읽어온 기존 행을 복원할 때 쓴다. */ - public static SurveyOption of(Long id, SurveyQuestionKey questionKey, String code, String label, + public static SurveyOption of(Long id, String questionKey, String code, String label, Integer value, int sortOrder, boolean exclusive, boolean active) { - return new SurveyOption(id, questionKey, code, label, sortOrder, exclusive, active); + return new SurveyOption(id, questionKey, code, label, value, sortOrder, exclusive, active); } - /** 문구·순서·단독선택 여부를 고친다. code와 active는 대상이 아니다. */ - public SurveyOption withDetails(String newLabel, int newSortOrder, boolean newExclusive) { - return new SurveyOption(id, questionKey, code, newLabel, newSortOrder, newExclusive, active); + /** 문구·점수·순서·단독선택 여부를 고친다. code와 active는 대상이 아니다. */ + public SurveyOption withDetails(String newLabel, Integer newValue, int newSortOrder, boolean newExclusive) { + return new SurveyOption(id, questionKey, code, newLabel, newValue, newSortOrder, newExclusive, active); } /** 노출/숨김. {@code false}가 곧 삭제다(행은 남는다). */ public SurveyOption withActive(boolean newActive) { - return new SurveyOption(id, questionKey, code, label, sortOrder, exclusive, newActive); + return new SurveyOption(id, questionKey, code, label, value, sortOrder, exclusive, newActive); } private static String requireCode(String code) { @@ -83,11 +83,19 @@ private static int requireNonNegative(int sortOrder) { return sortOrder; } + /** 카테고리성 선택지는 점수 개념이 없어 null을 허용한다. 있다면 0~100 범위여야 한다. */ + private static Integer requireValidValue(Integer value) { + if (value != null && (value < 0 || value > 100)) { + throw new IllegalArgumentException("점수는 0에서 100 사이여야 합니다."); + } + return value; + } + public Long getId() { return id; } - public SurveyQuestionKey getQuestionKey() { + public String getQuestionKey() { return questionKey; } @@ -99,6 +107,10 @@ public String getLabel() { return label; } + public Integer getValue() { + return value; + } + public int getSortOrder() { return sortOrder; } diff --git a/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyQuestion.java b/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyQuestion.java index 80e242c..5be05cd 100644 --- a/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyQuestion.java +++ b/src/main/java/com/seoulection/admin/survey/domain/entity/SurveyQuestion.java @@ -1,33 +1,63 @@ package com.seoulection.admin.survey.domain.entity; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; - -import java.util.Objects; +import java.util.regex.Pattern; /** - * 설문 문항 — 관리자는 문구({@code title})만 고친다. + * 설문 문항 — 관리자가 CRUD하는 대상(추가·문구 수정·숨김). + * + *

{@code questionKey}는 문자열 자연 PK다(api-server와 동일). 예전엔 {@code SurveyQuestionKey} enum이었지만, + * api-server의 제출 요청이 문항마다 고정 필드를 갖는 구조를 벗어나 문항 목록 형태로 바뀌면서 문항 집합을 + * 코드에 고정할 이유가 사라졌다 — 지금은 {@link SurveyOption}과 마찬가지로 행의 추가·숨김이 데이터만으로 가능하다. * - *

추가·삭제가 없는 이유: 문항 하나가 api-server 제출 요청 본문의 필드 하나와 1:1로 묶여 있다 - * ({@code AVOIDANCE → avoidances}). 행을 지우면 그 필드를 받는 쪽이 갈 곳을 잃는다. + *

삭제는 {@link SurveyOption}과 같은 이유로 {@link #withActive}(soft delete)만 제공한다 — 이미 이 문항으로 + * 답한 {@code survey_answer} 행이 있어, 하드 삭제하면 그 응답이 가리키는 문항 문구를 되찾을 수 없다. */ public class SurveyQuestion { - private final SurveyQuestionKey questionKey; + /** 대문자 스네이크. {@link SurveyOption#getCode()}와 같은 규칙 — api-server가 안정 키로 다룬다. */ + private static final Pattern QUESTION_KEY_FORMAT = Pattern.compile("^[A-Z][A-Z0-9_]*$"); + + private final String questionKey; private final String title; private final int sortOrder; + private final boolean active; - private SurveyQuestion(SurveyQuestionKey questionKey, String title, int sortOrder) { - this.questionKey = Objects.requireNonNull(questionKey, "questionKey"); + private SurveyQuestion(String questionKey, String title, int sortOrder, boolean active) { + this.questionKey = requireQuestionKey(questionKey); this.title = requireText(title); this.sortOrder = sortOrder; + this.active = active; } - public static SurveyQuestion of(SurveyQuestionKey questionKey, String title, int sortOrder) { - return new SurveyQuestion(questionKey, title, sortOrder); + /** 새 문항. 활성 상태로 시작한다. */ + public static SurveyQuestion create(String questionKey, String title, int sortOrder) { + return new SurveyQuestion(questionKey, title, sortOrder, true); + } + + /** 저장소가 읽어온 기존 행을 복원할 때 쓴다. */ + public static SurveyQuestion of(String questionKey, String title, int sortOrder, boolean active) { + return new SurveyQuestion(questionKey, title, sortOrder, active); } public SurveyQuestion withTitle(String newTitle) { - return new SurveyQuestion(questionKey, newTitle, sortOrder); + return new SurveyQuestion(questionKey, newTitle, sortOrder, active); + } + + /** 노출/숨김. {@code false}가 곧 삭제다(행은 남는다). */ + public SurveyQuestion withActive(boolean newActive) { + return new SurveyQuestion(questionKey, title, sortOrder, newActive); + } + + private static String requireQuestionKey(String questionKey) { + if (questionKey == null || questionKey.isBlank()) { + throw new IllegalArgumentException("문항 키를 입력해 주세요."); + } + String trimmed = questionKey.trim(); + if (!QUESTION_KEY_FORMAT.matcher(trimmed).matches()) { + throw new IllegalArgumentException( + "문항 키는 영문 대문자·숫자·밑줄만 쓸 수 있습니다(예: SKIN_TYPE_CHECK)."); + } + return trimmed; } private static String requireText(String title) { @@ -37,7 +67,7 @@ private static String requireText(String title) { return title.trim(); } - public SurveyQuestionKey getQuestionKey() { + public String getQuestionKey() { return questionKey; } @@ -48,4 +78,8 @@ public String getTitle() { public int getSortOrder() { return sortOrder; } + + public boolean isActive() { + return active; + } } diff --git a/src/main/java/com/seoulection/admin/survey/domain/enums/SurveyQuestionKey.java b/src/main/java/com/seoulection/admin/survey/domain/enums/SurveyQuestionKey.java deleted file mode 100644 index fcde08c..0000000 --- a/src/main/java/com/seoulection/admin/survey/domain/enums/SurveyQuestionKey.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.seoulection.admin.survey.domain.enums; - -/** - * 설문 문항 식별자. api-server의 동명 enum과 상수명이 일치해야 한다 — - * {@code survey_question.question_key} / {@code survey_option.question_key} 컬럼에 문자열로 저장되기 때문이다. - * - *

여기에 상수를 추가해도 api-server가 모르면 아무 일도 일어나지 않는다. 문항 집합은 양쪽 코드에 - * 고정돼 있고, 관리자가 바꾸는 건 문구와 선택지다. - */ -public enum SurveyQuestionKey { - - /** Q1 — 회피 성분/상태. 제출 요청의 {@code avoidances} 필드. */ - AVOIDANCE, - - /** Q2 — 피부 고민. 제출 요청의 {@code concerns} 필드. */ - CONCERN -} diff --git a/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyOptionRepository.java b/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyOptionRepository.java index aa74e21..9c6c3bf 100644 --- a/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyOptionRepository.java +++ b/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyOptionRepository.java @@ -1,7 +1,6 @@ package com.seoulection.admin.survey.domain.repository; import com.seoulection.admin.survey.domain.entity.SurveyOption; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import java.util.List; import java.util.Optional; @@ -10,11 +9,11 @@ public interface SurveyOptionRepository { /** 노출 순서로 정렬된 문항별 선택지(비활성 포함 — 관리 화면은 숨긴 것도 봐야 한다). */ - List findByQuestionKey(SurveyQuestionKey questionKey); + List findByQuestionKey(String questionKey); Optional findById(Long id); - boolean existsByQuestionKeyAndCode(SurveyQuestionKey questionKey, String code); + boolean existsByQuestionKeyAndCode(String questionKey, String code); SurveyOption save(SurveyOption option); } diff --git a/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyQuestionRepository.java b/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyQuestionRepository.java index 01f6f39..40d4317 100644 --- a/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyQuestionRepository.java +++ b/src/main/java/com/seoulection/admin/survey/domain/repository/SurveyQuestionRepository.java @@ -1,7 +1,6 @@ package com.seoulection.admin.survey.domain.repository; import com.seoulection.admin.survey.domain.entity.SurveyQuestion; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import java.util.List; import java.util.Optional; @@ -11,7 +10,9 @@ public interface SurveyQuestionRepository { List findAllOrdered(); - Optional findByKey(SurveyQuestionKey questionKey); + Optional findByKey(String questionKey); + + boolean existsByKey(String questionKey); SurveyQuestion save(SurveyQuestion question); } diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyOptionJpaEntity.java b/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyOptionJpaEntity.java index f1c67da..9cf0920 100644 --- a/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyOptionJpaEntity.java +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyOptionJpaEntity.java @@ -1,11 +1,8 @@ package com.seoulection.admin.survey.infrastructure.entity; import com.seoulection.admin.survey.domain.entity.SurveyOption; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -17,6 +14,9 @@ *

⚠️ 이 테이블은 api-server가 소유한다(그쪽 {@code SurveyOptionJpaEntity}의 매핑이 곧 DDL). * 여기는 {@code ddl-auto=none}으로 붙는 소비자이므로, 컬럼이 어긋나면 기동이 아니라 * 쿼리 실행 시점에 터진다. api-server 쪽 매핑을 바꾸면 이 클래스도 같이 고쳐야 한다. + * + *

{@code questionKey}는 문자열이다(enum 아님) — api-server가 문항 집합을 코드에 고정하지 않으므로 + * 여기서 enum으로 매핑하면 모르는 문항 키가 나올 때마다 조회가 예외로 터진다. */ @Entity @Table(name = "survey_option") @@ -26,9 +26,8 @@ public class SurveyOptionJpaEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Enumerated(EnumType.STRING) @Column(name = "question_key", nullable = false, length = 32) - private SurveyQuestionKey questionKey; + private String questionKey; @Column(nullable = false, length = 64, updatable = false) private String code; @@ -36,6 +35,10 @@ public class SurveyOptionJpaEntity { @Column(nullable = false) private String label; + /** 이 선택지의 점수(0~100). 카테고리성 선택지는 null. */ + @Column + private Integer value; + @Column(name = "sort_order", nullable = false) private int sortOrder; @@ -48,12 +51,13 @@ public class SurveyOptionJpaEntity { protected SurveyOptionJpaEntity() { } - private SurveyOptionJpaEntity(Long id, SurveyQuestionKey questionKey, String code, String label, + private SurveyOptionJpaEntity(Long id, String questionKey, String code, String label, Integer value, int sortOrder, boolean exclusive, boolean active) { this.id = id; this.questionKey = questionKey; this.code = code; this.label = label; + this.value = value; this.sortOrder = sortOrder; this.exclusive = exclusive; this.active = active; @@ -61,10 +65,11 @@ private SurveyOptionJpaEntity(Long id, SurveyQuestionKey questionKey, String cod public static SurveyOptionJpaEntity fromDomain(SurveyOption option) { return new SurveyOptionJpaEntity(option.getId(), option.getQuestionKey(), option.getCode(), - option.getLabel(), option.getSortOrder(), option.isExclusive(), option.isActive()); + option.getLabel(), option.getValue(), option.getSortOrder(), option.isExclusive(), + option.isActive()); } public SurveyOption toDomain() { - return SurveyOption.of(id, questionKey, code, label, sortOrder, exclusive, active); + return SurveyOption.of(id, questionKey, code, label, value, sortOrder, exclusive, active); } } diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionJpaEntity.java b/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionJpaEntity.java index 25d7a26..d5a144a 100644 --- a/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionJpaEntity.java +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/entity/SurveyQuestionJpaEntity.java @@ -1,49 +1,51 @@ package com.seoulection.admin.survey.infrastructure.entity; import com.seoulection.admin.survey.domain.entity.SurveyQuestion; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; /** * {@code survey_question} 영속성 엔티티. 스키마 주인은 api-server다(여기는 {@code ddl-auto=none}). * - *

{@code questionKey}가 자연 PK라 {@code save()}가 곧 upsert다 — 관리자의 문구 수정이 새 행을 만들지 않는다. + *

{@code questionKey}가 자연 PK(문자열, enum 아님)라 {@code save()}가 곧 upsert다 — + * 관리자의 문구 수정이 새 행을 만들지 않는다. */ @Entity @Table(name = "survey_question") public class SurveyQuestionJpaEntity { @Id - @Enumerated(EnumType.STRING) @Column(name = "question_key", nullable = false, length = 32) - private SurveyQuestionKey questionKey; + private String questionKey; - @Column(nullable = false) + @Column(nullable = false, length = 512) private String title; @Column(name = "sort_order", nullable = false) private int sortOrder; + /** soft delete 플래그. false면 GET /survey/questions에서 빠진다(api-server SurveyService 참조). */ + @Column(nullable = false) + private boolean active; + protected SurveyQuestionJpaEntity() { } - private SurveyQuestionJpaEntity(SurveyQuestionKey questionKey, String title, int sortOrder) { + private SurveyQuestionJpaEntity(String questionKey, String title, int sortOrder, boolean active) { this.questionKey = questionKey; this.title = title; this.sortOrder = sortOrder; + this.active = active; } public static SurveyQuestionJpaEntity fromDomain(SurveyQuestion question) { return new SurveyQuestionJpaEntity(question.getQuestionKey(), question.getTitle(), - question.getSortOrder()); + question.getSortOrder(), question.isActive()); } public SurveyQuestion toDomain() { - return SurveyQuestion.of(questionKey, title, sortOrder); + return SurveyQuestion.of(questionKey, title, sortOrder, active); } } diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionJpaRepository.java b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionJpaRepository.java index 2d2833c..1de56f5 100644 --- a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionJpaRepository.java +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionJpaRepository.java @@ -1,6 +1,5 @@ package com.seoulection.admin.survey.infrastructure.repository; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import com.seoulection.admin.survey.infrastructure.entity.SurveyOptionJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,7 +8,7 @@ /** Spring Data JPA 리포지토리(영속성 엔티티 전용, 내부용). */ interface SurveyOptionJpaRepository extends JpaRepository { - List findByQuestionKeyOrderBySortOrderAscCodeAsc(SurveyQuestionKey questionKey); + List findByQuestionKeyOrderBySortOrderAscCodeAsc(String questionKey); - boolean existsByQuestionKeyAndCode(SurveyQuestionKey questionKey, String code); + boolean existsByQuestionKeyAndCode(String questionKey, String code); } diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionRepositoryImpl.java b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionRepositoryImpl.java index b04f893..7c9d49e 100644 --- a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionRepositoryImpl.java +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyOptionRepositoryImpl.java @@ -1,7 +1,6 @@ package com.seoulection.admin.survey.infrastructure.repository; import com.seoulection.admin.survey.domain.entity.SurveyOption; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import com.seoulection.admin.survey.domain.repository.SurveyOptionRepository; import com.seoulection.admin.survey.infrastructure.entity.SurveyOptionJpaEntity; import org.springframework.stereotype.Repository; @@ -20,7 +19,7 @@ public SurveyOptionRepositoryImpl(SurveyOptionJpaRepository jpaRepository) { } @Override - public List findByQuestionKey(SurveyQuestionKey questionKey) { + public List findByQuestionKey(String questionKey) { // 정렬 기준(sortOrder → code)은 api-server의 SurveyOptionCatalog와 같아야 한다. // 관리 화면에서 보이는 순서가 곧 사용자 화면 순서여야 관리자가 결과를 예측할 수 있다. return jpaRepository.findByQuestionKeyOrderBySortOrderAscCodeAsc(questionKey).stream() @@ -34,7 +33,7 @@ public Optional findById(Long id) { } @Override - public boolean existsByQuestionKeyAndCode(SurveyQuestionKey questionKey, String code) { + public boolean existsByQuestionKeyAndCode(String questionKey, String code) { return jpaRepository.existsByQuestionKeyAndCode(questionKey, code); } diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionJpaRepository.java b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionJpaRepository.java index 1acf3ca..3f32b0c 100644 --- a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionJpaRepository.java +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionJpaRepository.java @@ -1,13 +1,12 @@ package com.seoulection.admin.survey.infrastructure.repository; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import com.seoulection.admin.survey.infrastructure.entity.SurveyQuestionJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; /** Spring Data JPA 리포지토리(영속성 엔티티 전용, 내부용). */ -interface SurveyQuestionJpaRepository extends JpaRepository { +interface SurveyQuestionJpaRepository extends JpaRepository { List findAllByOrderBySortOrderAsc(); } diff --git a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionRepositoryImpl.java b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionRepositoryImpl.java index 36bec3a..ac43ddb 100644 --- a/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionRepositoryImpl.java +++ b/src/main/java/com/seoulection/admin/survey/infrastructure/repository/SurveyQuestionRepositoryImpl.java @@ -1,7 +1,6 @@ package com.seoulection.admin.survey.infrastructure.repository; import com.seoulection.admin.survey.domain.entity.SurveyQuestion; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import com.seoulection.admin.survey.domain.repository.SurveyQuestionRepository; import com.seoulection.admin.survey.infrastructure.entity.SurveyQuestionJpaEntity; import org.springframework.stereotype.Repository; @@ -27,10 +26,15 @@ public List findAllOrdered() { } @Override - public Optional findByKey(SurveyQuestionKey questionKey) { + public Optional findByKey(String questionKey) { return jpaRepository.findById(questionKey).map(SurveyQuestionJpaEntity::toDomain); } + @Override + public boolean existsByKey(String questionKey) { + return jpaRepository.existsById(questionKey); + } + @Override public SurveyQuestion save(SurveyQuestion question) { return jpaRepository.save(SurveyQuestionJpaEntity.fromDomain(question)).toDomain(); 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 2841309..a961b26 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 @@ -1,8 +1,8 @@ package com.seoulection.admin.survey.presentation.controller; import com.seoulection.admin.survey.application.service.SurveyAdminService; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import com.seoulection.admin.survey.presentation.dto.SurveyOptionCreateRequest; +import com.seoulection.admin.survey.presentation.dto.SurveyQuestionCreateRequest; import jakarta.validation.Valid; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -34,26 +34,58 @@ public String page(Model model) { if (!model.containsAttribute("request")) { model.addAttribute("request", new SurveyOptionCreateRequest()); } + if (!model.containsAttribute("questionRequest")) { + model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); + } model.addAttribute("questions", service.getQuestions()); return "survey"; } + /** + * 문항 추가. api-server가 문항 집합을 코드에 고정하지 않으므로 여기서 자유롭게 늘릴 수 있다 — + * 단 {@code questionKey}는 자연 PK라 중복이면 거부한다(서비스 주석 참조). + */ + @PostMapping("/admin/survey/questions") + public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQuestionCreateRequest request, + BindingResult bindingResult, + Model model, + RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + model.addAttribute("request", new SurveyOptionCreateRequest()); + model.addAttribute("questions", service.getQuestions()); + return "survey"; + } + + try { + service.createQuestion(request.getQuestionKey(), request.getTitle(), request.getSortOrder()); + } catch (IllegalArgumentException e) { + bindingResult.rejectValue("questionKey", "invalid", e.getMessage()); + model.addAttribute("request", new SurveyOptionCreateRequest()); + model.addAttribute("questions", service.getQuestions()); + return "survey"; + } + redirectAttributes.addFlashAttribute("successMessage", "문항을 추가했습니다."); + return "redirect:/admin/survey"; + } + @PostMapping("/admin/survey/options") public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateRequest request, BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { + model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); model.addAttribute("questions", service.getQuestions()); return "survey"; } try { - service.createOption(SurveyQuestionKey.valueOf(request.getQuestionKey()), - request.getCode(), request.getLabel(), request.getSortOrder(), request.isExclusive()); + service.createOption(request.getQuestionKey(), request.getCode(), request.getLabel(), + request.getValue(), request.getSortOrder(), request.isExclusive()); } catch (IllegalArgumentException e) { // 코드 중복·형식 위반은 사용자가 고칠 수 있는 입력 오류다 → 폼으로 되돌려 사유를 보여준다. bindingResult.rejectValue("code", "invalid", e.getMessage()); + model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); model.addAttribute("questions", service.getQuestions()); return "survey"; } @@ -64,10 +96,11 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR @PostMapping("/admin/survey/options/{optionId}") public String updateOption(@PathVariable Long optionId, @RequestParam String label, + @RequestParam(required = false) Integer value, @RequestParam int sortOrder, @RequestParam(defaultValue = "false") boolean exclusive, RedirectAttributes redirectAttributes) { - service.updateOption(optionId, label, sortOrder, exclusive); + service.updateOption(optionId, label, value, sortOrder, exclusive); redirectAttributes.addFlashAttribute("successMessage", "선택지를 수정했습니다."); return "redirect:/admin/survey"; } @@ -84,11 +117,22 @@ public String changeOptionActive(@PathVariable Long optionId, } @PostMapping("/admin/survey/questions/{questionKey}") - public String updateQuestionTitle(@PathVariable SurveyQuestionKey questionKey, + public String updateQuestionTitle(@PathVariable String questionKey, @RequestParam String title, RedirectAttributes redirectAttributes) { service.updateQuestionTitle(questionKey, title); redirectAttributes.addFlashAttribute("successMessage", "질문 문구를 수정했습니다."); return "redirect:/admin/survey"; } + + /** 숨김/되살리기. 이것이 문항 삭제다 — 행을 지우지 않는 이유는 서비스 주석 참조. */ + @PostMapping("/admin/survey/questions/{questionKey}/active") + public String changeQuestionActive(@PathVariable String questionKey, + @RequestParam boolean active, + RedirectAttributes redirectAttributes) { + service.changeQuestionActive(questionKey, active); + redirectAttributes.addFlashAttribute("successMessage", + active ? "문항을 다시 노출합니다." : "문항을 숨겼습니다. 기존 응답은 그대로 남습니다."); + return "redirect:/admin/survey"; + } } diff --git a/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyOptionCreateRequest.java b/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyOptionCreateRequest.java index d02cf7e..d331014 100644 --- a/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyOptionCreateRequest.java +++ b/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyOptionCreateRequest.java @@ -1,5 +1,7 @@ package com.seoulection.admin.survey.presentation.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -26,6 +28,11 @@ public class SurveyOptionCreateRequest { @Size(max = 255, message = "노출 문구는 255자 이하여야 합니다.") private String label; + /** 카테고리성 선택지(회피 항목 등)는 점수 개념이 없으므로 비워 둘 수 있다. */ + @Min(value = 0, message = "점수는 0 이상이어야 합니다.") + @Max(value = 100, message = "점수는 100 이하여야 합니다.") + private Integer value; + @NotNull(message = "노출 순서를 입력해 주세요.") @PositiveOrZero(message = "노출 순서는 0 이상이어야 합니다.") private Integer sortOrder; @@ -56,6 +63,14 @@ public void setLabel(String label) { this.label = label; } + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + public Integer getSortOrder() { return sortOrder; } diff --git a/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyQuestionCreateRequest.java b/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyQuestionCreateRequest.java new file mode 100644 index 0000000..bb0fd12 --- /dev/null +++ b/src/main/java/com/seoulection/admin/survey/presentation/dto/SurveyQuestionCreateRequest.java @@ -0,0 +1,54 @@ +package com.seoulection.admin.survey.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +/** + * 문항 추가 폼. + * + *

{@code questionKey}는 자연 PK다 — 생성 후엔 사실상 못 바꾼다(제출된 응답과 화면 코드가 이 문자열을 + * 그대로 참조한다). + */ +public class SurveyQuestionCreateRequest { + + @NotBlank(message = "문항 키를 입력해 주세요.") + @Size(max = 32, message = "문항 키는 32자 이하여야 합니다.") + @Pattern(regexp = "[A-Za-z][A-Za-z0-9_]*", + message = "문항 키는 영문으로 시작하고 영문·숫자·밑줄만 쓸 수 있습니다(예: SKIN_TYPE_CHECK).") + private String questionKey; + + @NotBlank(message = "문항 문구를 입력해 주세요.") + @Size(max = 512, message = "문항 문구는 512자 이하여야 합니다.") + private String title; + + @NotNull(message = "노출 순서를 입력해 주세요.") + @PositiveOrZero(message = "노출 순서는 0 이상이어야 합니다.") + private Integer sortOrder; + + public String getQuestionKey() { + return questionKey; + } + + public void setQuestionKey(String questionKey) { + this.questionKey = questionKey; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css new file mode 100644 index 0000000..9e9b8ac --- /dev/null +++ b/src/main/resources/static/css/admin.css @@ -0,0 +1,449 @@ +/* + * Seoulection Admin — 공용 디자인 시스템. + * + * 이전엔 페이지마다(products.html, videos.html, ...) + -

-

SEOULECTION ADMIN

-

관리자 홈

-

관리할 항목을 선택해 주세요.

- - -
+ diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html index 69044ab..eeb4326 100644 --- a/src/main/resources/templates/products.html +++ b/src/main/resources/templates/products.html @@ -4,102 +4,78 @@ 제품 관리 | Seoulection Admin - + -
- -

제품 등록

-

제품 기본 정보를 등록하면 분석 파이프라인이 나머지 정보를 채웁니다.

-
+
+ +
+
+
+

제품 관리

+

제품 기본 정보를 등록하면 분석 파이프라인이 나머지 정보를 채웁니다.

+
+
+
+
-
-
-
-
- - -

-
-
- - -

-
-
- 카테고리 -
- - - - - - +
+

제품 등록

+ +
+
+ + +

+
+
+ + +

+
+
+ 카테고리 +
+ + + + + + +
+

+
-

-
-
- - -
+ + + -
-

등록된 제품

- - - - - - - - - - - - - - -
제품명브랜드카테고리언급 수광고 비율상태
-

등록된 제품이 없습니다.

-
-
+
+

등록된 제품

+ + + + + + + + + + + + + + +
제품명브랜드카테고리언급 수광고 비율상태
+
+ + + + 등록된 제품이 없습니다. +
+
+
+ + diff --git a/src/main/resources/templates/survey.html b/src/main/resources/templates/survey.html index e8051fb..004969c 100644 --- a/src/main/resources/templates/survey.html +++ b/src/main/resources/templates/survey.html @@ -4,154 +4,157 @@ 설문 관리 | Seoulection Admin - + -
- -

설문 관리

-

사용자 설문의 질문 문구와 선택지를 관리합니다. 저장 즉시 사용자 화면에 반영됩니다.

-
+
+ +
+
+
+

설문 관리

+

사용자 설문의 질문 문구와 선택지를 관리합니다. 저장 즉시 사용자 화면에 반영됩니다.

+
+
+
+
-

- 코드(code)는 만든 뒤 바꿀 수 없습니다. - 이미 제출된 사용자 응답이 이 문자열을 그대로 참조하고 있어, 코드를 바꾸면 과거 응답이 가리키는 대상이 사라집니다. - 같은 이유로 삭제는 '숨김'으로 동작합니다 — 신규 설문에서만 사라지고 기존 응답은 보존됩니다. -

+

+ 문항 키(questionKey)·선택지 코드(code)는 만든 뒤 바꿀 수 없습니다. + 이미 제출된 사용자 응답이 이 문자열을 그대로 참조하고 있어, 바꾸면 과거 응답이 가리키는 대상이 사라집니다. + 선택지의 삭제는 '숨김'으로 동작합니다 — 신규 설문에서만 사라지고 기존 응답은 보존됩니다. +

-
-

선택지 추가

-
-
-
- 문항 -
- - +
+

문항 추가

+ +
+
+ + +

+
+
+ + +

+
+
+ + +

+
-

-
-
- - -

-
-
- - -

-
-
- - -

-
-
- 단독 선택 -
- + + +
+ +
+

선택지 추가

+
+
+
+ 문항 +
+ +
+

+
+
+ + +

+
+
+ + +

+
+
+ + +

+
+
+ + +

+
+
+ 단독 선택 +
+ +
+
-
-
- - - + + + -
-
- - - -
+
+
+ + + + 노출 + 숨김 +
+
+ + +
- - - - - - - - - - - - -
코드노출 문구순서단독상태
-
- - - - -
-
- 노출 - 숨김 - -
- - -
-
-

등록된 선택지가 없습니다.

-
-
+ + + + + + + + + + + + +
코드노출 문구점수순서단독상태
+
+ + + + + +
+
+ 노출 + 숨김 + +
+ + +
+
+
+ + + + 등록된 선택지가 없습니다. +
+ + + + diff --git a/src/main/resources/templates/videos.html b/src/main/resources/templates/videos.html index e8d4457..92a28d9 100644 --- a/src/main/resources/templates/videos.html +++ b/src/main/resources/templates/videos.html @@ -4,75 +4,58 @@ YouTube 영상 관리 | Seoulection Admin - + -
- -

YouTube 영상 등록

-

파이프라인에서 분석할 YouTube 영상 링크를 등록합니다.

-
- -
-
- -
- - +
+ +
+
+
+

YouTube 영상 관리

+

파이프라인에서 분석할 YouTube 영상 링크를 등록합니다.

-

- -
+ +
+
+ +
+

영상 등록

+
+ +
+ + +
+

+
+
-
-

등록된 영상

- - - - - - - - - - - -
영상 ID제목유튜버 IDURL상태
-

등록된 영상이 없습니다.

-
-
+
+

등록된 영상

+ + + + + + + + + + + +
영상 ID제목유튜버 IDURL상태
+
+ + + + 등록된 영상이 없습니다. +
+
+
+ + diff --git a/src/main/resources/templates/youtubers.html b/src/main/resources/templates/youtubers.html index 98c0bd9..1f23482 100644 --- a/src/main/resources/templates/youtubers.html +++ b/src/main/resources/templates/youtubers.html @@ -4,78 +4,66 @@ YouTube 채널 관리 | Seoulection Admin - + -
- -

유튜버 채널 등록

-

파이프라인이 주기적으로 확인할 YouTube 채널 링크를 등록합니다.

-
- -
-
- -
- -
-

- -
- - +
+ +
+
+
+

유튜버 채널 관리

+

파이프라인이 주기적으로 확인할 YouTube 채널 링크를 등록합니다.

-

- -
+ +
+
+ +
+

채널 등록

+
+
+
+ + +

+
+
+ +
+ +
+

+
+
+ +
+
-
-

등록된 채널

- - - - - - - - - - -
채널명채널 IDURL마지막 확인
-

등록된 채널이 없습니다.

-
-
+
+

등록된 채널

+ + + + + + + + + + +
채널명채널 IDURL마지막 확인
+
+ + + + 등록된 채널이 없습니다. +
+
+
+ + diff --git a/src/test/java/com/seoulection/admin/survey/application/SurveyAdminServiceTest.java b/src/test/java/com/seoulection/admin/survey/application/SurveyAdminServiceTest.java index a38d81e..90847c9 100644 --- a/src/test/java/com/seoulection/admin/survey/application/SurveyAdminServiceTest.java +++ b/src/test/java/com/seoulection/admin/survey/application/SurveyAdminServiceTest.java @@ -4,7 +4,6 @@ import com.seoulection.admin.survey.application.dto.SurveyOptionResult; import com.seoulection.admin.survey.application.dto.SurveyQuestionResult; import com.seoulection.admin.survey.application.service.SurveyAdminService; -import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,8 +38,8 @@ void setUp() { jdbcTemplate.update("delete from survey_option"); jdbcTemplate.update("delete from survey_question"); jdbcTemplate.update(""" - insert into survey_question (question_key, title, sort_order) - values ('AVOIDANCE', '회피 항목', 1), ('CONCERN', '피부 고민', 2)"""); + insert into survey_question (question_key, title, sort_order, active) + values ('AVOIDANCE', '회피 항목', 1, true), ('CONCERN', '피부 고민', 2, true)"""); } private List avoidanceOptions() { @@ -50,10 +49,67 @@ private List avoidanceOptions() { .options(); } + @Test + @DisplayName("문항을 추가하면 목록에 새 문항이 나타난다 — 문항 집합은 코드에 고정돼 있지 않다") + void createQuestion_addsNewQuestion() { + service.createQuestion("water_direct", "피부 수분 상태는 어떤가요?", 3); + + List questions = service.getQuestions(); + assertThat(questions).hasSize(3); + assertThat(questions.stream().map(SurveyQuestionResult::key)).contains("WATER_DIRECT"); + } + + @Test + @DisplayName("이미 있는 문항 키로 또 추가하면 거부된다 — 자연 PK라 그냥 저장하면 upsert로 덮어써 버린다") + void createQuestion_duplicateKey_rejected() { + assertThatThrownBy(() -> service.createQuestion("AVOIDANCE", "다른 문구", 9)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 있는 문항"); + } + + @Test + @DisplayName("새 문항은 활성 상태로 생성된다") + void createQuestion_startsActive() { + service.createQuestion("WATER_DIRECT", "피부 수분 상태는 어떤가요?", 3); + + assertThat(questionByKey("WATER_DIRECT").active()).isTrue(); + } + + @Test + @DisplayName("★ 문항 숨김은 행을 지우지 않는다(soft delete) — 되살릴 수 있고 목록·과거 응답은 그대로 남는다") + void changeQuestionActive_isSoftDelete() { + service.changeQuestionActive("AVOIDANCE", false); + + // 관리 화면은 숨긴 문항도 계속 보여줘야 되살릴 수 있다. + List questions = service.getQuestions(); + assertThat(questions).hasSize(2); + assertThat(questionByKey("AVOIDANCE").active()).isFalse(); + Long rows = jdbcTemplate.queryForObject( + "select count(*) from survey_question where question_key = 'AVOIDANCE'", Long.class); + assertThat(rows).isEqualTo(1L); + + service.changeQuestionActive("AVOIDANCE", true); + assertThat(questionByKey("AVOIDANCE").active()).isTrue(); + } + + @Test + @DisplayName("없는 문항을 숨기려 하면 거부된다") + void changeQuestionActive_unknownKey_rejected() { + assertThatThrownBy(() -> service.changeQuestionActive("NOT_A_QUESTION", false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("없는 문항입니다"); + } + + private SurveyQuestionResult questionByKey(String key) { + return service.getQuestions().stream() + .filter(q -> q.key().equals(key)) + .findFirst().orElseThrow(); + } + @Test @DisplayName("선택지를 추가하면 활성 상태로 저장되고 코드는 대문자로 정규화된다") void createOption_savesActiveWithUppercasedCode() { - service.createOption(SurveyQuestionKey.AVOIDANCE, "fragrance_allergy", "향료 회피", 3, false); + service.createOption("AVOIDANCE", "fragrance_allergy", "향료 회피", null, 3, false); SurveyOptionResult saved = avoidanceOptions().get(0); assertThat(saved.code()).isEqualTo("FRAGRANCE_ALLERGY"); @@ -62,13 +118,25 @@ void createOption_savesActiveWithUppercasedCode() { assertThat(saved.active()).isTrue(); } + @Test + @DisplayName("점수가 있는 선택지(자가진단 문항 등)는 value가 그대로 저장·수정된다") + void createOption_withValue_savesScore() { + service.createOption("AVOIDANCE", "WATER_BALANCED", "적당하다", 66, 1, true); + + SurveyOptionResult saved = avoidanceOptions().get(0); + assertThat(saved.value()).isEqualTo(66); + + service.updateOption(saved.id(), saved.label(), 100, saved.sortOrder(), saved.exclusive()); + assertThat(avoidanceOptions().get(0).value()).isEqualTo(100); + } + @Test @DisplayName("같은 문항에 같은 코드를 또 추가하면 거부된다 — 응답이 어느 선택지를 가리키는지 모호해지므로") void createOption_duplicateCode_rejected() { - service.createOption(SurveyQuestionKey.AVOIDANCE, "PREGNANT", "임신 중", 1, false); + service.createOption("AVOIDANCE", "PREGNANT", "임신 중", null, 1, false); assertThatThrownBy(() -> - service.createOption(SurveyQuestionKey.AVOIDANCE, "PREGNANT", "다른 문구", 2, false)) + service.createOption("AVOIDANCE", "PREGNANT", "다른 문구", null, 2, false)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("이미 있는 코드"); } @@ -77,17 +145,17 @@ void createOption_duplicateCode_rejected() { @DisplayName("소문자·공백이 섞인 코드 형식은 거부된다") void createOption_invalidCodeFormat_rejected() { assertThatThrownBy(() -> - service.createOption(SurveyQuestionKey.AVOIDANCE, "fragrance allergy", "향료", 1, false)) + service.createOption("AVOIDANCE", "fragrance allergy", "향료", null, 1, false)) .isInstanceOf(IllegalArgumentException.class); } @Test - @DisplayName("★ 수정은 문구·순서·단독선택만 바꾸고 code는 그대로다 — 기존 응답이 가리키는 키가 유지된다") + @DisplayName("★ 수정은 문구·점수·순서·단독선택만 바꾸고 code는 그대로다 — 기존 응답이 가리키는 키가 유지된다") void updateOption_keepsCode() { - service.createOption(SurveyQuestionKey.AVOIDANCE, "PREGNANT", "임신 중", 1, false); + service.createOption("AVOIDANCE", "PREGNANT", "임신 중", null, 1, false); Long id = avoidanceOptions().get(0).id(); - service.updateOption(id, "임신 중이에요", 9, true); + service.updateOption(id, "임신 중이에요", null, 9, true); SurveyOptionResult updated = avoidanceOptions().get(0); assertThat(updated.code()).isEqualTo("PREGNANT"); @@ -99,7 +167,7 @@ void updateOption_keepsCode() { @Test @DisplayName("★ 숨김은 행을 지우지 않는다(soft delete) — 되살릴 수 있고 과거 응답의 문구도 남는다") void changeOptionActive_isSoftDelete() { - service.createOption(SurveyQuestionKey.AVOIDANCE, "PREGNANT", "임신 중", 1, false); + service.createOption("AVOIDANCE", "PREGNANT", "임신 중", null, 1, false); Long id = avoidanceOptions().get(0).id(); service.changeOptionActive(id, false); @@ -117,8 +185,8 @@ void changeOptionActive_isSoftDelete() { @Test @DisplayName("선택지는 sortOrder 순으로 조회된다 — 관리 화면 순서가 곧 사용자 화면 순서다") void getQuestions_ordersBySortOrder() { - service.createOption(SurveyQuestionKey.AVOIDANCE, "SECOND", "둘째", 2, false); - service.createOption(SurveyQuestionKey.AVOIDANCE, "FIRST", "첫째", 1, false); + service.createOption("AVOIDANCE", "SECOND", "둘째", null, 2, false); + service.createOption("AVOIDANCE", "FIRST", "첫째", null, 1, false); assertThat(avoidanceOptions().stream().map(SurveyOptionResult::code)) .containsExactly("FIRST", "SECOND"); @@ -127,7 +195,7 @@ void getQuestions_ordersBySortOrder() { @Test @DisplayName("질문 문구 수정은 새 행을 만들지 않는다(자연 PK upsert)") void updateQuestionTitle_upsertsInPlace() { - service.updateQuestionTitle(SurveyQuestionKey.AVOIDANCE, "바뀐 질문 문구"); + service.updateQuestionTitle("AVOIDANCE", "바뀐 질문 문구"); List questions = service.getQuestions(); assertThat(questions).hasSize(2);