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,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<FeedbackResponseDto> 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";
}
}
Original file line number Diff line number Diff line change
@@ -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<StudyRoom> pageResult = studyRoomRepository.findAll(
PageRequest.of(page, size, Sort.by("createdAt").descending()));

List<Long> roomIds = pageResult.stream().map(StudyRoom::getId).toList();

Map<Long, Long> memberCountMap = studyRoomMemberRepository.countMembersByRoomIds(roomIds)
.stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));

Map<Long, Long> sharedProblemCountMap = studyRoomSharedProblemRepository.countSharedProblemsByRoomIds(roomIds)
.stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));

List<AdminStudyRoomSummaryDto> 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<StudyRoomMember> members = studyRoomMemberRepository.findAllWithUserByRoomId(id);
List<StudyRoomChallenge> challenges = studyRoomChallengeRepository.findAllByRoomIdOrderByEndAtAsc(id);
long sharedProblemCount = studyRoomSharedProblemRepository.countByRoomId(id);

model.addAttribute("room", AdminStudyRoomDetailDto.from(room, members, challenges, sharedProblemCount));
return "admin-study-room-detail";
}
}
Original file line number Diff line number Diff line change
@@ -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<MemberInfo> members;
private List<ChallengeInfo> 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<StudyRoomMember> members,
List<StudyRoomChallenge> 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 -> "만료";
};
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> usagePurpose;
private String usagePurposeOther;

// Q2: 사용 빈도
private String usageFrequency;

// Q3: NPS
private Integer npsScore;

// Q4: 등록·조회 불편 (다중 선택 + 기타 텍스트)
private List<String> registrationPainPoints;
private String registrationPainPointsOther;

// Q5: 분류 방식
private String classificationMethod;

// Q6: 템플릿 적합도
private Integer templateSatisfaction;

// Q_복습세트 사용 여부 (true=사용 중, false=사용 안 함)
private Boolean practiceNoteUsed;

// 복습 세트 사용 중일 때 → Q7: 알림 효과
private String notificationEffectiveness;

// 복습 세트 미사용 이유 (복수 선택 + 기타 텍스트)
private List<String> reviewSetNonUsageReason;
private String reviewSetNonUsageReasonOther;

// Q10: 스터디룸 사용 여부 ("사용 중" / "사용하지 않음")
private String studyRoomUsage;

// 스터디룸 미사용 이유 (복수 선택 + 기타 텍스트)
private List<String> studyRoomNonUsageReason;
private String studyRoomNonUsageReasonOther;

// 스터디룸 사용 중일 때
// Q11: 챌린지 동기 부여
private Integer challengeMotivation;

// Q12: 오답 공유 유용도
private Integer problemSharingUsefulness;

// Q13: 자주 쓰는 기능
private String mostUsedFeature;

// Q14: 불편한 점
private String painPoints;

// Q15: 원하는 기능
private String desiredFeatures;
}
Loading
Loading