From 6d6fc6835a2daa710262e2e63b08a8640b372245 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 30 Jun 2026 21:11:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Feat]=20=EC=9C=A0=EC=A0=80=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20DB=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(V18~V20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V18: user_feedback 테이블 신규 생성 (NPS, 사용 목적, 빈도, 불편사항 등 전체 설문 필드) - V19: Hibernate Integer 매핑 불일치 수정 — TINYINT → INT (nps_score 등 6개 컬럼) - V20: 복습 세트·스터디룸 미사용 이유 VARCHAR 컬럼 2개 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V18__add_user_feedback.sql | 24 +++++++++++++++++++ .../V19__fix_user_feedback_column_types.sql | 7 ++++++ .../V20__add_feedback_non_usage_reasons.sql | 3 +++ 3 files changed, 34 insertions(+) create mode 100644 src/main/resources/db/migration/V18__add_user_feedback.sql create mode 100644 src/main/resources/db/migration/V19__fix_user_feedback_column_types.sql create mode 100644 src/main/resources/db/migration/V20__add_feedback_non_usage_reasons.sql diff --git a/src/main/resources/db/migration/V18__add_user_feedback.sql b/src/main/resources/db/migration/V18__add_user_feedback.sql new file mode 100644 index 00000000..6d622564 --- /dev/null +++ b/src/main/resources/db/migration/V18__add_user_feedback.sql @@ -0,0 +1,24 @@ +CREATE TABLE user_feedback ( + id BIGINT NOT NULL AUTO_INCREMENT, + usage_purpose VARCHAR(500), + usage_frequency VARCHAR(50), + nps_score TINYINT, + registration_pain_points VARCHAR(500), + classification_method VARCHAR(50), + template_satisfaction TINYINT, + notification_effectiveness VARCHAR(50), + review_interval_satisfaction TINYINT, + practice_note_used BOOLEAN, + practice_note_usefulness TINYINT, + study_room_usage VARCHAR(50), + challenge_motivation TINYINT, + problem_sharing_usefulness TINYINT, + most_used_feature VARCHAR(100), + pain_points TEXT, + desired_features TEXT, + ip_address VARCHAR(50), + submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V19__fix_user_feedback_column_types.sql b/src/main/resources/db/migration/V19__fix_user_feedback_column_types.sql new file mode 100644 index 00000000..403655d5 --- /dev/null +++ b/src/main/resources/db/migration/V19__fix_user_feedback_column_types.sql @@ -0,0 +1,7 @@ +ALTER TABLE user_feedback + MODIFY COLUMN nps_score INT, + MODIFY COLUMN template_satisfaction INT, + MODIFY COLUMN review_interval_satisfaction INT, + MODIFY COLUMN practice_note_usefulness INT, + MODIFY COLUMN challenge_motivation INT, + MODIFY COLUMN problem_sharing_usefulness INT; diff --git a/src/main/resources/db/migration/V20__add_feedback_non_usage_reasons.sql b/src/main/resources/db/migration/V20__add_feedback_non_usage_reasons.sql new file mode 100644 index 00000000..5ca4a51b --- /dev/null +++ b/src/main/resources/db/migration/V20__add_feedback_non_usage_reasons.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_feedback + ADD COLUMN study_room_non_usage_reason VARCHAR(300) AFTER study_room_usage, + ADD COLUMN review_set_non_usage_reason VARCHAR(300) AFTER practice_note_usefulness; From 394acc5addfa6968236d6e6239714af8a6400a73 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 30 Jun 2026 21:11:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Feat]=20=EC=9C=A0=EC=A0=80=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=88=98=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /feedback 페이지: 5개 섹션 15문항 설문 폼 (익명, 녹색 테마, 반응형) - 복수 선택 체크박스 + 기타 직접 입력, 조건부 하위 질문 (복습 세트·스터디룸 사용 여부) - FeedbackService: 다중 선택 콤마 직렬화, 제출 시 Discord 웹훅 알림 발송 - /feedback/complete 완료 페이지 - SecurityConfig: /feedback/** permitAll 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../backend/auth/config/SecurityConfig.java | 4 +- .../controller/FeedbackController.java | 44 ++ .../feedback/dto/FeedbackRequestDto.java | 64 ++ .../feedback/dto/FeedbackResponseDto.java | 56 ++ .../backend/feedback/entity/UserFeedback.java | 79 ++ .../repository/UserFeedbackRepository.java | 17 + .../feedback/service/FeedbackService.java | 119 +++ .../templates/feedback-complete.html | 40 + src/main/resources/templates/feedback.html | 681 ++++++++++++++++++ 9 files changed, 1103 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java create mode 100644 src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java create mode 100644 src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java create mode 100644 src/main/resources/templates/feedback-complete.html create mode 100644 src/main/resources/templates/feedback.html diff --git a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java index d162df1f..7eb840a2 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java +++ b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java @@ -95,7 +95,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/grafana", "/grafana/**", "/prometheus", - "/prometheus/**" + "/prometheus/**", + "/feedback", + "/feedback/**" ).permitAll() .requestMatchers("/api/auth/logout").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/auth/**").permitAll() diff --git a/src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java b/src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java new file mode 100644 index 00000000..ae678c6d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.feedback.controller; + +import com.aisip.OnO.backend.feedback.dto.FeedbackRequestDto; +import com.aisip.OnO.backend.feedback.service.FeedbackService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/feedback") +@RequiredArgsConstructor +public class FeedbackController { + + private final FeedbackService feedbackService; + + @GetMapping + public String feedbackForm() { + return "feedback"; + } + + @PostMapping + public String submit(@ModelAttribute FeedbackRequestDto dto, HttpServletRequest request) { + String ip = resolveClientIp(request); + feedbackService.save(dto, ip); + return "redirect:/feedback/complete"; + } + + @GetMapping("/complete") + public String complete() { + return "feedback-complete"; + } + + private String resolveClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank()) { + return xff.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java new file mode 100644 index 00000000..391c953f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java @@ -0,0 +1,64 @@ +package com.aisip.OnO.backend.feedback.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class FeedbackRequestDto { + + // Q1: 사용 목적 (다중 선택 + 기타 텍스트) + private List usagePurpose; + private String usagePurposeOther; + + // Q2: 사용 빈도 + private String usageFrequency; + + // Q3: NPS + private Integer npsScore; + + // Q4: 등록·조회 불편 (다중 선택 + 기타 텍스트) + private List registrationPainPoints; + private String registrationPainPointsOther; + + // Q5: 분류 방식 + private String classificationMethod; + + // Q6: 템플릿 적합도 + private Integer templateSatisfaction; + + // Q_복습세트 사용 여부 (true=사용 중, false=사용 안 함) + private Boolean practiceNoteUsed; + + // 복습 세트 사용 중일 때 → Q7: 알림 효과 + private String notificationEffectiveness; + + // 복습 세트 미사용 이유 (복수 선택 + 기타 텍스트) + private List reviewSetNonUsageReason; + private String reviewSetNonUsageReasonOther; + + // Q10: 스터디룸 사용 여부 ("사용 중" / "사용하지 않음") + private String studyRoomUsage; + + // 스터디룸 미사용 이유 (복수 선택 + 기타 텍스트) + private List studyRoomNonUsageReason; + private String studyRoomNonUsageReasonOther; + + // 스터디룸 사용 중일 때 + // Q11: 챌린지 동기 부여 + private Integer challengeMotivation; + + // Q12: 오답 공유 유용도 + private Integer problemSharingUsefulness; + + // Q13: 자주 쓰는 기능 + private String mostUsedFeature; + + // Q14: 불편한 점 + private String painPoints; + + // Q15: 원하는 기능 + private String desiredFeatures; +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java new file mode 100644 index 00000000..cf61f75d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.feedback.dto; + +import com.aisip.OnO.backend.feedback.entity.UserFeedback; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class FeedbackResponseDto { + + private Long id; + private String usagePurpose; + private String usageFrequency; + private Integer npsScore; + private String registrationPainPoints; + private String classificationMethod; + private Integer templateSatisfaction; + private Boolean practiceNoteUsed; + private String notificationEffectiveness; + private String reviewSetNonUsageReason; + private String studyRoomUsage; + private String studyRoomNonUsageReason; + private Integer challengeMotivation; + private Integer problemSharingUsefulness; + private String mostUsedFeature; + private String painPoints; + private String desiredFeatures; + private String ipAddress; + private LocalDateTime submittedAt; + + public static FeedbackResponseDto from(UserFeedback f) { + return FeedbackResponseDto.builder() + .id(f.getId()) + .usagePurpose(f.getUsagePurpose()) + .usageFrequency(f.getUsageFrequency()) + .npsScore(f.getNpsScore()) + .registrationPainPoints(f.getRegistrationPainPoints()) + .classificationMethod(f.getClassificationMethod()) + .templateSatisfaction(f.getTemplateSatisfaction()) + .practiceNoteUsed(f.getPracticeNoteUsed()) + .notificationEffectiveness(f.getNotificationEffectiveness()) + .reviewSetNonUsageReason(f.getReviewSetNonUsageReason()) + .studyRoomUsage(f.getStudyRoomUsage()) + .studyRoomNonUsageReason(f.getStudyRoomNonUsageReason()) + .challengeMotivation(f.getChallengeMotivation()) + .problemSharingUsefulness(f.getProblemSharingUsefulness()) + .mostUsedFeature(f.getMostUsedFeature()) + .painPoints(f.getPainPoints()) + .desiredFeatures(f.getDesiredFeatures()) + .ipAddress(f.getIpAddress()) + .submittedAt(f.getSubmittedAt()) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java b/src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java new file mode 100644 index 00000000..f416abff --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java @@ -0,0 +1,79 @@ +package com.aisip.OnO.backend.feedback.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_feedback") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UserFeedback { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "usage_purpose", length = 500) + private String usagePurpose; + + @Column(name = "usage_frequency", length = 50) + private String usageFrequency; + + @Column(name = "nps_score") + private Integer npsScore; + + @Column(name = "registration_pain_points", length = 500) + private String registrationPainPoints; + + @Column(name = "classification_method", length = 50) + private String classificationMethod; + + @Column(name = "template_satisfaction") + private Integer templateSatisfaction; + + @Column(name = "notification_effectiveness", length = 50) + private String notificationEffectiveness; + + @Column(name = "review_interval_satisfaction") + private Integer reviewIntervalSatisfaction; + + @Column(name = "practice_note_used") + private Boolean practiceNoteUsed; + + @Column(name = "practice_note_usefulness") + private Integer practiceNoteUsefulness; + + @Column(name = "review_set_non_usage_reason", length = 300) + private String reviewSetNonUsageReason; + + @Column(name = "study_room_usage", length = 50) + private String studyRoomUsage; + + @Column(name = "study_room_non_usage_reason", length = 300) + private String studyRoomNonUsageReason; + + @Column(name = "challenge_motivation") + private Integer challengeMotivation; + + @Column(name = "problem_sharing_usefulness") + private Integer problemSharingUsefulness; + + @Column(name = "most_used_feature", length = 100) + private String mostUsedFeature; + + @Column(name = "pain_points", columnDefinition = "TEXT") + private String painPoints; + + @Column(name = "desired_features", columnDefinition = "TEXT") + private String desiredFeatures; + + @Column(name = "ip_address", length = 50) + private String ipAddress; + + @Column(name = "submitted_at") + private LocalDateTime submittedAt; +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java b/src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java new file mode 100644 index 00000000..69df1132 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java @@ -0,0 +1,17 @@ +package com.aisip.OnO.backend.feedback.repository; + +import com.aisip.OnO.backend.feedback.entity.UserFeedback; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.OptionalDouble; + +public interface UserFeedbackRepository extends JpaRepository { + + Page findAllByOrderBySubmittedAtDesc(Pageable pageable); + + @Query("SELECT AVG(f.npsScore) FROM UserFeedback f WHERE f.npsScore IS NOT NULL") + Double findAverageNpsScore(); +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java b/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java new file mode 100644 index 00000000..63924edb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java @@ -0,0 +1,119 @@ +package com.aisip.OnO.backend.feedback.service; + +import com.aisip.OnO.backend.feedback.dto.FeedbackRequestDto; +import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto; +import com.aisip.OnO.backend.feedback.entity.UserFeedback; +import com.aisip.OnO.backend.feedback.repository.UserFeedbackRepository; +import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FeedbackService { + + private final UserFeedbackRepository feedbackRepository; + private final DiscordWebhookNotificationService discordWebhookNotificationService; + + @Transactional + public void save(FeedbackRequestDto dto, String ipAddress) { + boolean reviewSetUsed = Boolean.TRUE.equals(dto.getPracticeNoteUsed()); + boolean studyRoomUsed = "사용 중".equals(dto.getStudyRoomUsage()); + + UserFeedback feedback = UserFeedback.builder() + .usagePurpose(multiSelectWithOther(dto.getUsagePurpose(), dto.getUsagePurposeOther())) + .usageFrequency(dto.getUsageFrequency()) + .npsScore(dto.getNpsScore()) + .registrationPainPoints(multiSelectWithOther(dto.getRegistrationPainPoints(), dto.getRegistrationPainPointsOther())) + .classificationMethod(dto.getClassificationMethod()) + .templateSatisfaction(dto.getTemplateSatisfaction()) + .practiceNoteUsed(dto.getPracticeNoteUsed()) + .notificationEffectiveness(reviewSetUsed ? dto.getNotificationEffectiveness() : null) + .reviewSetNonUsageReason(!reviewSetUsed ? multiSelectWithOther(dto.getReviewSetNonUsageReason(), dto.getReviewSetNonUsageReasonOther()) : null) + .studyRoomUsage(dto.getStudyRoomUsage()) + .studyRoomNonUsageReason(!studyRoomUsed ? multiSelectWithOther(dto.getStudyRoomNonUsageReason(), dto.getStudyRoomNonUsageReasonOther()) : null) + .challengeMotivation(studyRoomUsed ? dto.getChallengeMotivation() : null) + .problemSharingUsefulness(studyRoomUsed ? dto.getProblemSharingUsefulness() : null) + .mostUsedFeature(dto.getMostUsedFeature()) + .painPoints(nullIfBlank(dto.getPainPoints())) + .desiredFeatures(nullIfBlank(dto.getDesiredFeatures())) + .ipAddress(ipAddress) + .submittedAt(LocalDateTime.now()) + .build(); + + feedbackRepository.save(feedback); + notifyDiscord(feedback); + } + + public Page findAll(int page, int size) { + return feedbackRepository + .findAllByOrderBySubmittedAtDesc(PageRequest.of(page, size)) + .map(FeedbackResponseDto::from); + } + + public FeedbackResponseDto findById(Long id) { + return feedbackRepository.findById(id) + .map(FeedbackResponseDto::from) + .orElseThrow(() -> new IllegalArgumentException("피드백을 찾을 수 없습니다: " + id)); + } + + public long count() { + return feedbackRepository.count(); + } + + public Double averageNps() { + return feedbackRepository.findAverageNpsScore(); + } + + private void notifyDiscord(UserFeedback f) { + try { + StringBuilder sb = new StringBuilder(); + appendField(sb, "NPS", f.getNpsScore() != null ? f.getNpsScore() + " / 10" : null); + appendField(sb, "사용 목적", f.getUsagePurpose()); + appendField(sb, "사용 빈도", f.getUsageFrequency()); + appendField(sb, "복습 세트", f.getPracticeNoteUsed() == null ? null + : (f.getPracticeNoteUsed() ? "사용 중" : "미사용")); + appendField(sb, "스터디룸", f.getStudyRoomUsage()); + appendField(sb, "불편한 점", f.getPainPoints()); + appendField(sb, "원하는 기능", f.getDesiredFeatures()); + discordWebhookNotificationService.sendMessage("📋 새 유저 피드백 도착", sb.toString().trim()); + } catch (Exception e) { + // 알림 실패가 저장 트랜잭션에 영향을 주지 않도록 로그만 남김 + log.warn("Discord 피드백 알림 전송 실패: {}", e.getMessage()); + } + } + + private void appendField(StringBuilder sb, String label, String value) { + if (value != null && !value.isBlank()) { + sb.append("**").append(label).append("**: ").append(value).append("\n"); + } + } + + private String multiSelectWithOther(List items, String otherText) { + boolean hasOther = otherText != null && !otherText.isBlank(); + if (CollectionUtils.isEmpty(items)) { + return hasOther ? "기타: " + otherText.trim() : null; + } + List result = new ArrayList<>(items); + if (hasOther) { + result.remove("기타"); + result.add("기타: " + otherText.trim()); + } + return String.join(",", result); + } + + private String nullIfBlank(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } +} diff --git a/src/main/resources/templates/feedback-complete.html b/src/main/resources/templates/feedback-complete.html new file mode 100644 index 00000000..c0403d46 --- /dev/null +++ b/src/main/resources/templates/feedback-complete.html @@ -0,0 +1,40 @@ + + + + + + 피드백 제출 완료 — OnO + + + + + +
+
+ + + +
+

감사합니다!

+

+ 소중한 피드백을 보내주셨습니다.
+ 더 나은 OnO를 만드는 데 큰 도움이 됩니다. +

+
+ + diff --git a/src/main/resources/templates/feedback.html b/src/main/resources/templates/feedback.html new file mode 100644 index 00000000..cbdf116a --- /dev/null +++ b/src/main/resources/templates/feedback.html @@ -0,0 +1,681 @@ + + + + + + OnO 사용자 피드백 + + + + + + + + + +
+
+ OnO +
+
+ +
+ + +
+
+ + + + 익명으로 제출됩니다 +
+

+ OnO를 더 좋게 만들어 주세요 ✏️ +

+

+ 모든 질문은 선택 사항입니다.
+ 소중한 의견은 서비스 개선에만 활용됩니다. +

+
+ +
+ + + +
+
+ 1 + 만족도 & 사용 패턴 +
+ + +
+

OnO를 주로 어떤 목적으로 사용하나요? (복수 선택 가능)

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

얼마나 자주 앱을 사용하나요?

+
+ + + + +
+
+ + +
+

OnO를 친구에게 추천할 의향은? + (0 = 전혀 아니다 · 10 = 강력 추천) +

+
+ +
+ 0510 +
+
+ 7 +
+
+
+
+ + +
+
+ 2 + 오답 등록 & 관리 +
+ + +
+

문제를 등록하거나 조회할 때 가장 불편한 점은? (복수 선택 가능)

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

폴더와 태그 중 어떤 방식으로 주로 문제를 분류하나요?

+
+ + + + +
+
+ +
+ + +
+
+ 3 + 복습 세트 +
+ + +
+

복습 세트를 사용하고 있나요?

+
+ + +
+ + +
+

복습 알림을 받으면 실제로 복습하게 되나요?

+
+ + + + +
+
+ + +
+

왜 사용하지 않나요? (복수 선택 가능)

+
+ + + + +
+ +
+
+
+ + +
+
+ 4 + 소셜 & 스터디룸 +
+ + +
+

스터디룸을 사용하고 있나요?

+
+ + +
+ + +
+ +
+

챌린지 기능이 실제 학습 동기 부여에 도움이 되나요?

+
+ 전혀 도움 안 됨 +
+ +
+ 매우 도움 됨 +
+
+ + +
+

다른 사람의 오답 문제를 공유받는 기능이 유용한가요?

+
+ 전혀 유용하지 않다 +
+ +
+ 매우 유용하다 +
+
+
+ + +
+

왜 사용하지 않나요? (복수 선택 가능)

+
+ + + + +
+ +
+
+
+ + +
+
+ 5 + 개선 요청 & 자유 의견 +
+ + +
+

가장 자주 쓰는 기능은 무엇인가요?

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

+ 제출 후에는 수정이 불가합니다 · 익명으로 처리됩니다 +

+
+
+
+ + + + From 8a0f61bc0a9f8b6b98f676c9721b277f4ceb5fbc Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 30 Jun 2026 21:11:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/feedbacks: 제출 목록 (페이지네이션, 총 수, 평균 NPS 표시) - /admin/feedbacks/{id}: 섹션별 상세 응답 뷰 (NPS 색상 코딩 포함) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AdminFeedbackController.java | 52 ++++++ .../templates/admin-feedback-detail.html | 168 ++++++++++++++++++ .../resources/templates/admin-feedback.html | 153 ++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java create mode 100644 src/main/resources/templates/admin-feedback-detail.html create mode 100644 src/main/resources/templates/admin-feedback.html diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java new file mode 100644 index 00000000..f021a716 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java @@ -0,0 +1,52 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto; +import com.aisip.OnO.backend.feedback.service.FeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +@RequestMapping("/admin/feedbacks") +@RequiredArgsConstructor +public class AdminFeedbackController { + + private final FeedbackService feedbackService; + + @GetMapping + public String feedbackList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Model model + ) { + Page pageResult = feedbackService.findAll(page, size); + + model.addAttribute("feedbacks", pageResult.getContent()); + model.addAttribute("totalCount", feedbackService.count()); + model.addAttribute("averageNps", feedbackService.averageNps()); + model.addAttribute("currentPage", page); + model.addAttribute("totalPages", pageResult.getTotalPages()); + model.addAttribute("size", size); + + int blockSize = 10; + int blockStart = (page / blockSize) * blockSize; + int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1); + model.addAttribute("pageBlockStart", blockStart); + model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart)); + model.addAttribute("hasPreviousBlock", blockStart > 0); + model.addAttribute("hasNextBlock", blockEnd < pageResult.getTotalPages() - 1); + + return "admin-feedback"; + } + + @GetMapping("/{id}") + public String feedbackDetail(@PathVariable Long id, Model model) { + model.addAttribute("feedback", feedbackService.findById(id)); + return "admin-feedback-detail"; + } +} diff --git a/src/main/resources/templates/admin-feedback-detail.html b/src/main/resources/templates/admin-feedback-detail.html new file mode 100644 index 00000000..300e2f5e --- /dev/null +++ b/src/main/resources/templates/admin-feedback-detail.html @@ -0,0 +1,168 @@ + + + + + + OnO 관리자 - 피드백 상세 + + + + + + +
+
+ + + + + +
+

피드백 상세

+

+ #1 · + - + · IP: - +

+
+
+ +
+ + +
+

1. 만족도 & 사용 패턴

+
+
+
사용 목적
+
-
+
+
+
사용 빈도
+
-
+
+
+
NPS 점수
+
-
+
+
+
+ + +
+

2. 오답 등록 & 관리

+
+
+
등록·조회 불편 사항
+
-
+
+
+
분류 방식
+
-
+
+
+
템플릿 적합도
+
-
+
+
+
+ + +
+

3. 복습 세트

+
+
+
복습 세트 사용 여부
+
-
+
+
+
알림 효과
+
-
+
+
+
미사용 이유
+
-
+
+
+
+ + +
+

4. 소셜 & 스터디룸

+
+
+
스터디룸 사용 여부
+
-
+
+
+
미사용 이유
+
-
+
+
+
챌린지 동기 부여
+
-
+
+
+
오답 공유 유용도
+
-
+
+
+
+ + +
+

5. 개선 요청 & 자유 의견

+
+
+
자주 쓰는 기능
+
-
+
+
+
없애고 싶은 불편함
+
-
+
+
+
원하는 기능
+
-
+
+
+
+ +
+ + +
+ + diff --git a/src/main/resources/templates/admin-feedback.html b/src/main/resources/templates/admin-feedback.html new file mode 100644 index 00000000..b52199af --- /dev/null +++ b/src/main/resources/templates/admin-feedback.html @@ -0,0 +1,153 @@ + + + + + + OnO 관리자 - 유저 피드백 + + + + + + +
+ +
+

유저 피드백

+

총 0건의 응답

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

총 응답 수

+

0

+
+
+
+
+ + + +
+
+

평균 NPS

+

+ - + 응답 없음 + / 10 +

+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID제출일시사용 목적빈도NPS자주 쓰는 기능
1--- + - + - + - + 상세 보기 +
아직 피드백이 없습니다
+
+
+ + +
+
+ 페이지 1 / 1 +
+
+ 이전 + 이전 + + << + + + + 1 + + + 1 + + + + >> + + 다음 + 다음 +
+
+
+ + From 0b3491045920b8b2b0aa086d9df9bbf1815e0502 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 30 Jun 2026 21:11:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=94=94=EB=A3=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/study-rooms: 룸 목록 (멤버 수·공유 문제 수 배치 집계, 페이지네이션) - /admin/study-rooms/{id}: 기본 정보·멤버 목록·챌린지 목록 상세 뷰 - StudyRoomSharedProblemRepository: countByRoomId·배치 집계 쿼리 추가 - admin.html: 스터디룸 카드 추가, 유저 피드백 카드 추가, 빠른 이동 섹션 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AdminStudyRoomController.java | 90 +++++++++++ .../admin/dto/AdminStudyRoomDetailDto.java | 100 ++++++++++++ .../admin/dto/AdminStudyRoomSummaryDto.java | 28 ++++ .../StudyRoomSharedProblemRepository.java | 6 + .../templates/admin-study-room-detail.html | 148 ++++++++++++++++++ .../templates/admin-study-rooms.html | 88 +++++++++++ src/main/resources/templates/admin.html | 53 ++++--- 7 files changed, 495 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java create mode 100644 src/main/resources/templates/admin-study-room-detail.html create mode 100644 src/main/resources/templates/admin-study-rooms.html diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java new file mode 100644 index 00000000..5b7795f5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java @@ -0,0 +1,90 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomDetailDto; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomSummaryDto; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Controller +@RequestMapping("/admin/study-rooms") +@RequiredArgsConstructor +public class AdminStudyRoomController { + + private final StudyRoomRepository studyRoomRepository; + private final StudyRoomMemberRepository studyRoomMemberRepository; + private final StudyRoomChallengeRepository studyRoomChallengeRepository; + private final StudyRoomSharedProblemRepository studyRoomSharedProblemRepository; + + @GetMapping + public String list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Model model + ) { + Page pageResult = studyRoomRepository.findAll( + PageRequest.of(page, size, Sort.by("createdAt").descending())); + + List roomIds = pageResult.stream().map(StudyRoom::getId).toList(); + + Map memberCountMap = studyRoomMemberRepository.countMembersByRoomIds(roomIds) + .stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + + Map sharedProblemCountMap = studyRoomSharedProblemRepository.countSharedProblemsByRoomIds(roomIds) + .stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + + List rooms = pageResult.stream() + .map(r -> AdminStudyRoomSummaryDto.from( + r, + memberCountMap.getOrDefault(r.getId(), 0L), + sharedProblemCountMap.getOrDefault(r.getId(), 0L))) + .toList(); + + model.addAttribute("rooms", rooms); + model.addAttribute("totalCount", pageResult.getTotalElements()); + model.addAttribute("currentPage", page); + model.addAttribute("totalPages", pageResult.getTotalPages()); + model.addAttribute("size", size); + + int blockSize = 10; + int blockStart = (page / blockSize) * blockSize; + int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1); + model.addAttribute("pageBlockStart", blockStart); + model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart)); + model.addAttribute("hasPreviousBlock", blockStart > 0); + model.addAttribute("hasNextBlock", blockEnd < pageResult.getTotalPages() - 1); + + return "admin-study-rooms"; + } + + @GetMapping("/{id}") + public String detail(@PathVariable Long id, Model model) { + StudyRoom room = studyRoomRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("스터디룸을 찾을 수 없습니다: " + id)); + + List members = studyRoomMemberRepository.findAllWithUserByRoomId(id); + List challenges = studyRoomChallengeRepository.findAllByRoomIdOrderByEndAtAsc(id); + long sharedProblemCount = studyRoomSharedProblemRepository.countByRoomId(id); + + model.addAttribute("room", AdminStudyRoomDetailDto.from(room, members, challenges, sharedProblemCount)); + return "admin-study-room-detail"; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java new file mode 100644 index 00000000..98027eb9 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java @@ -0,0 +1,100 @@ +package com.aisip.OnO.backend.admin.dto; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallengeStatus; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class AdminStudyRoomDetailDto { + + private Long id; + private String name; + private Long hostUserId; + private LocalDateTime createdAt; + private long sharedProblemCount; + private List members; + private List challenges; + + @Getter + @Builder + public static class MemberInfo { + private Long userId; + private String userName; + private String role; // 방장 / 멤버 + private LocalDateTime joinedAt; + } + + @Getter + @Builder + public static class ChallengeInfo { + private Long id; + private String title; + private String type; // 개인 / 그룹 / 연속 + private String metric; + private String status; // 진행 중 / 완료 / 실패 / 만료 + private Integer targetValue; + private LocalDateTime startAt; + private LocalDateTime endAt; + private LocalDateTime completedAt; + private boolean isActive; + } + + public static AdminStudyRoomDetailDto from(StudyRoom room, + List members, + List challenges, + long sharedProblemCount) { + return AdminStudyRoomDetailDto.builder() + .id(room.getId()) + .name(room.getName()) + .hostUserId(room.getHostUserId()) + .createdAt(room.getCreatedAt()) + .sharedProblemCount(sharedProblemCount) + .members(members.stream() + .map(m -> MemberInfo.builder() + .userId(m.getUser().getId()) + .userName(m.getUser().getName()) + .role(m.getRole().name().equals("HOST") ? "방장" : "멤버") + .joinedAt(m.getCreatedAt()) + .build()) + .toList()) + .challenges(challenges.stream() + .map(c -> ChallengeInfo.builder() + .id(c.getId()) + .title(c.getTitle()) + .type(translateType(c)) + .metric(c.getMetric().name()) + .status(translateStatus(c.getStatus())) + .targetValue(c.getTargetValue()) + .startAt(c.getStartAt()) + .endAt(c.getEndAt()) + .completedAt(c.getCompletedAt()) + .isActive(c.getStatus() == StudyRoomChallengeStatus.IN_PROGRESS) + .build()) + .toList()) + .build(); + } + + private static String translateType(StudyRoomChallenge c) { + return switch (c.getType()) { + case INDIVIDUAL -> "개인"; + case GROUP -> "그룹"; + case STREAK -> "연속"; + }; + } + + private static String translateStatus(StudyRoomChallengeStatus s) { + return switch (s) { + case IN_PROGRESS -> "진행 중"; + case COMPLETED -> "완료"; + case FAILED -> "실패"; + case EXPIRED -> "만료"; + }; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java new file mode 100644 index 00000000..61fdc99e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java @@ -0,0 +1,28 @@ +package com.aisip.OnO.backend.admin.dto; + +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class AdminStudyRoomSummaryDto { + + private Long id; + private String name; + private long memberCount; + private long sharedProblemCount; + private LocalDateTime createdAt; + + public static AdminStudyRoomSummaryDto from(StudyRoom room, long memberCount, long sharedProblemCount) { + return AdminStudyRoomSummaryDto.builder() + .id(room.getId()) + .name(room.getName()) + .memberCount(memberCount) + .sharedProblemCount(sharedProblemCount) + .createdAt(room.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java index 06587ad4..eaf1726a 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java @@ -7,11 +7,17 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; public interface StudyRoomSharedProblemRepository extends JpaRepository { + long countByRoomId(Long roomId); + + @Query("select sp.room.id, count(sp) from StudyRoomSharedProblem sp where sp.room.id in :roomIds group by sp.room.id") + List countSharedProblemsByRoomIds(@Param("roomIds") Collection roomIds); + @Query("select sp from StudyRoomSharedProblem sp join fetch sp.sharedByUser join fetch sp.problem where sp.room.id = :roomId order by sp.createdAt desc") Page findPageByRoomId(@Param("roomId") Long roomId, Pageable pageable); diff --git a/src/main/resources/templates/admin-study-room-detail.html b/src/main/resources/templates/admin-study-room-detail.html new file mode 100644 index 00000000..707eaee3 --- /dev/null +++ b/src/main/resources/templates/admin-study-room-detail.html @@ -0,0 +1,148 @@ + + + + + + 스터디룸 상세 — OnO Admin + + + + + +
+
+ + + + + +

스터디룸 이름

+ # +
+
+ +
+ + +
+

+ 기본 정보 +

+
+
+
룸 ID
+
+
+
+
방장 ID
+
+
+
+
생성일
+
+
+
+
전체 멤버
+
+
+
+
공유 문제 수
+
+
+
+
전체 챌린지
+
+
+
+
+ + +
+
+ +

멤버 목록

+ +
+ + + + + + + + + + + + + + + + + + + + +
유저 ID이름역할참여일
+ 방장 + 멤버 +
멤버가 없습니다.
+
+ + +
+
+ +

챌린지 목록

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID제목유형지표목표상태기간
+ + + + + + ~ + +
챌린지가 없습니다.
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin-study-rooms.html b/src/main/resources/templates/admin-study-rooms.html new file mode 100644 index 00000000..e614db6a --- /dev/null +++ b/src/main/resources/templates/admin-study-rooms.html @@ -0,0 +1,88 @@ + + + + + + 스터디룸 관리 — OnO Admin + + + + + +
+
+
+ + + + + +

스터디룸 관리

+
+ 전체 0 개 +
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
ID룸 이름멤버 수공유 문제 수생성일상세
+ + + + + 명 + + + 상세 보기 → +
스터디룸이 없습니다.
+
+ + +
+ « + + + + + + » +
+ +
+ + diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index d9381100..5e2b098d 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -95,6 +95,24 @@

복습노트 관리

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

스터디룸 관리

+

스터디룸 목록 조회

+
+
+
@@ -112,25 +130,24 @@

통계 분석

시스템 통계 및 인사이트 보기

- - -