diff --git a/src/main/java/com/assu/server/domain/admin/service/AdminService.java b/src/main/java/com/assu/server/domain/admin/service/AdminService.java index ece352c2..b7dd3f11 100644 --- a/src/main/java/com/assu/server/domain/admin/service/AdminService.java +++ b/src/main/java/com/assu/server/domain/admin/service/AdminService.java @@ -8,7 +8,6 @@ import com.assu.server.domain.user.entity.enums.Major; import com.assu.server.domain.user.entity.enums.University; -// PaperQueryServiceImpl 이 AdminService 참조 중 -> 순환참조 문제 발생하지 않도록 주의 public interface AdminService { List findMatchingAdmins(University university, Department department, Major major); diff --git a/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java b/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java index 9487feb1..d35d7fdd 100644 --- a/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java +++ b/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java @@ -6,6 +6,7 @@ import com.assu.server.global.apiPayload.code.status.SuccessStatus; import com.assu.server.global.util.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -15,21 +16,27 @@ @RestController @RequiredArgsConstructor @RequestMapping("/admin/dashBoard") +@Tag(name = "관리자 대시보드 API", description = "어드민 전용 통계 및 대시보드 데이터 조회 API") public class StudentAdminController { + private final StudentAdminService studentAdminService; + @Operation( summary = "누적 가입자 수 조회 API", - description = "admin으로 접근해주세요." + description = "# [v1.0 (2025-09-02)](https://www.notion.so/_-24c1197c19ed8062be94fc08619b760f)\n" + + "- 관리자(Admin) 권한으로 접근하여 현재까지의 총 누적 가입자 수를 조회합니다." ) @GetMapping public BaseResponse getCountAdmin( @AuthenticationPrincipal PrincipalDetails pd - ) { + ) { return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountAdminAuth(pd.getId())); } + @Operation( summary = "신규 한 달 가입자 수 조회 API", - description = "admin으로 접근해주세요." + description = "# [v1.0 (2025-09-02)](https://www.notion.so/_-24c1197c19ed805db80fca98c38849d1)\n" + + "- 이번 달(매달 1일 초기화) 기준 신규 가입한 사용자 수를 조회합니다." ) @GetMapping("/new") public BaseResponse getNewStudentCountAdmin( @@ -40,7 +47,8 @@ public BaseResponse getNewStud @Operation( summary = "오늘 제휴 사용자 수 조회 API", - description = "admin으로 접근해주세요." + description = "# [v1.0 (2025-09-02)](https://www.notion.so/_-24e1197c19ed80a283b1c336a1c3df72)\n" + + "- 금일 제휴 서비스를 이용한 총 사용자 수를 조회합니다." ) @GetMapping("/countUser") public BaseResponse getCountUser( @@ -48,29 +56,28 @@ public BaseResponse getCoun ){ return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsagePerson(pd.getId())); } + @Operation( summary = "제휴업체 누적별 1위 업체 조회 API", - description = "adminId로 접근해주세요." + description = "# [v1.0 (2025-09-02)](https://www.notion.so/_1-2ef1197c19ed8010a49ce6313d137b4f)\n" + + "- 제휴 이용 횟수가 가장 많은 1위 업체의 정보를 조회합니다." ) - @GetMapping("/top") - public BaseResponse getTopUsage( - @AuthenticationPrincipal PrincipalDetails pd + @GetMapping("/top") + public BaseResponse getTopUsage( + @AuthenticationPrincipal PrincipalDetails pd ) { - return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsage(pd.getId())); - } - - /** - * 제휴 업체별 누적 제휴 이용 현황 리스트 반환 (사용량 내림차순) - */ - @Operation( - summary = "제휴업체 누적 사용 수 내림차순 조회 API", - description = "adminId로 접근해주세요." - ) - @GetMapping("/usage") - public BaseResponse getUsageList( - @AuthenticationPrincipal PrincipalDetails pd - ) { - return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsageList(pd.getId())); - } + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsage(pd.getId())); + } -} + @Operation( + summary = "제휴업체 누적 사용 수 내림차순 조회 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/_-24e1197c19ed802b92eff5d4dc4dbe82)\n" + + "- 모든 제휴 업체의 누적 사용 현황을 사용량 내림차순 리스트로 반환합니다." + ) + @GetMapping("/usage") + public BaseResponse getUsageList( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsageList(pd.getId())); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/mapping/converter/StudentAdminConverter.java b/src/main/java/com/assu/server/domain/mapping/converter/StudentAdminConverter.java deleted file mode 100644 index 1af32e80..00000000 --- a/src/main/java/com/assu/server/domain/mapping/converter/StudentAdminConverter.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.assu.server.domain.mapping.converter; - -import com.assu.server.domain.admin.entity.Admin; -import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; -import com.assu.server.domain.partnership.entity.Paper; -import com.assu.server.domain.user.entity.PartnershipUsage; - -import java.util.List; - -public class StudentAdminConverter { - - public static StudentAdminResponseDTO.CountAdminAuthResponseDTO countAdminAuthDTO(Long adminId, Long total, String adminName) { - return StudentAdminResponseDTO.CountAdminAuthResponseDTO.builder() - .adminId(adminId) - .studentCount(total) - .adminName(adminName) - .build(); - } - - public static StudentAdminResponseDTO.NewCountAdminResponseDTO newCountAdminResponseDTO(Long adminId, Long total, String adminName){ - return StudentAdminResponseDTO.NewCountAdminResponseDTO.builder() - .adminId(adminId) - .newStudentCount(total) - .adminName(adminName) - .build(); - } - //오늘 사용자수 - public static StudentAdminResponseDTO.CountUsagePersonResponseDTO countUsagePersonDTO(Long adminId, Long total, String adminName){ - return StudentAdminResponseDTO.CountUsagePersonResponseDTO.builder() - .adminId(adminId) - .usagePersonCount(total) - .adminName(adminName) - .build(); - } - //업체별 누적 사용건수 - public static StudentAdminResponseDTO.CountUsageResponseDTO countUsageResponseDTO(Admin admin, Paper paper, Long total) { - return StudentAdminResponseDTO.CountUsageResponseDTO.builder() - .usageCount(total) - .adminId(admin.getId()) - .adminName(admin.getName()) - .storeId(paper.getStore().getId()) - .storeName(paper.getStore().getName()) - .build(); - } - public static StudentAdminResponseDTO.CountUsageListResponseDTO countUsageListResponseDTO(List countUsageList) { - return StudentAdminResponseDTO.CountUsageListResponseDTO.builder() - .items(countUsageList) - .build(); - } -} diff --git a/src/main/java/com/assu/server/domain/mapping/dto/StoreUsageWithPaper.java b/src/main/java/com/assu/server/domain/mapping/dto/StoreUsageWithPaper.java new file mode 100644 index 00000000..0035feae --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/dto/StoreUsageWithPaper.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.mapping.dto; + +public record StoreUsageWithPaper( + Long paperId, + Long storeId, + String storeName, + Long usageCount +) {} diff --git a/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java b/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java index 11145298..47520a0f 100644 --- a/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java +++ b/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java @@ -1,60 +1,70 @@ package com.assu.server.domain.mapping.dto; -import java.util.List; -import lombok.AllArgsConstructor; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.partnership.entity.Paper; import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import java.util.List; public class StudentAdminResponseDTO { - @Getter - @NoArgsConstructor - @AllArgsConstructor @Builder - public static class CountAdminAuthResponseDTO{ // admin에 따른 총 누적 가입자 수 - private Long studentCount; - private Long adminId; - private String adminName; + public record CountAdminAuthResponseDTO( + Long studentCount, + Long adminId, + String adminName + ) { + public static CountAdminAuthResponseDTO from(Long adminId, Long total, String adminName) { + return new CountAdminAuthResponseDTO(total, adminId, adminName); + } } - @Getter - @NoArgsConstructor - @AllArgsConstructor + @Builder - public static class NewCountAdminResponseDTO{ //신규 가입자수 (매달 1일 초기화) - private Long newStudentCount; - private Long adminId; - private String adminName; + public record NewCountAdminResponseDTO( + Long newStudentCount, + Long adminId, + String adminName + ) { + public static NewCountAdminResponseDTO from(Long adminId, Long total, String adminName) { + return new NewCountAdminResponseDTO(total, adminId, adminName); + } } - @Getter - @NoArgsConstructor - @AllArgsConstructor @Builder - public static class CountUsagePersonResponseDTO{ - private Long usagePersonCount; - private Long adminId; - private String adminName; + public record CountUsagePersonResponseDTO( + Long usagePersonCount, + Long adminId, + String adminName + ) { + public static CountUsagePersonResponseDTO from(Long adminId, Long total, String adminName) { + return new CountUsagePersonResponseDTO(total, adminId, adminName); + } } - @Getter - @NoArgsConstructor - @AllArgsConstructor @Builder - public static class CountUsageResponseDTO{ //제휴 업체별 누적 제휴 이용현황 - private Long usageCount; - private Long adminId; - private String adminName; - private Long storeId; - private String storeName; - + public record CountUsageResponseDTO( + Long usageCount, + Long adminId, + String adminName, + Long storeId, + String storeName + ) { + public static CountUsageResponseDTO from(Admin admin, Paper paper, Long total) { + return CountUsageResponseDTO.builder() + .usageCount(total) + .adminId(admin.getId()) + .adminName(admin.getName()) + .storeId(paper.getStore().getId()) + .storeName(paper.getStore().getName()) + .build(); + } } - @Getter - @NoArgsConstructor - @AllArgsConstructor + @Builder - public static class CountUsageListResponseDTO { - private List items; // 사용량 내림차순 정렬됨 + public record CountUsageListResponseDTO( + List items + ) { + public static CountUsageListResponseDTO from(List countUsageList) { + return new CountUsageListResponseDTO(countUsageList); + } } - -} +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java b/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java index bac1def6..6a5a3c5c 100644 --- a/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java +++ b/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java @@ -1,95 +1,47 @@ package com.assu.server.domain.mapping.repository; +import com.assu.server.domain.mapping.dto.StoreUsageWithPaper; +import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; import com.assu.server.domain.mapping.entity.StudentAdmin; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.YearMonth; -import java.util.Collection; import java.util.List; public interface StudentAdminRepository extends JpaRepository { - // 총 누적 가입자 수 - @Query(""" - select count(sa) - from StudentAdmin sa - where sa.admin.id = :adminId - """) + @Query("select count(sa) from StudentAdmin sa where sa.admin.id = :adminId") Long countAllByAdminId(@Param("adminId") Long adminId); - // 기간별 가입자 수 - @Query(""" - select count(sa) - from StudentAdmin sa - where sa.admin.id = :adminId - and sa.createdAt >= :from - and sa.createdAt < :to - """) - Long countByAdminIdBetween(@Param("adminId") Long adminId, - @Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); + @Query("select count(sa) from StudentAdmin sa where sa.admin.id = :adminId and sa.createdAt >= :from and sa.createdAt < :to") + Long countByAdminIdBetween(@Param("adminId") Long adminId, @Param("from") LocalDateTime from, @Param("to") LocalDateTime to); - // 이번 달 신규 가입자 수 - default Long countThisMonthByAdminId(Long adminId) { - LocalDateTime from = YearMonth.now().atDay(1).atStartOfDay(); - LocalDateTime to = LocalDateTime.now(); - return countByAdminIdBetween(adminId, from, to); - } + @Query(""" - // 오늘 제휴 사용 고유 사용자 수 - @Query(value = """ - SELECT COUNT(DISTINCT pu.student_id) - FROM partnership_usage pu - JOIN paper_content pc ON pc.id = pu.paper_id - JOIN paper p ON p.id = pc.paper_id - WHERE p.admin_id = :adminId - AND pu.created_at >= CURRENT_DATE - AND pu.created_at < CURRENT_DATE + INTERVAL 1 DAY - """, nativeQuery = true) - Long countTodayUsersByAdmin(@Param("adminId") Long adminId); + SELECT COUNT(DISTINCT pu.student.id) + FROM PartnershipUsage pu, Paper p + WHERE pu.paperId = p.id + AND p.admin.id = :adminId + AND pu.createdAt >= :start + AND pu.createdAt < :end + """) + Long countTodayUsersByAdmin( + @Param("adminId") Long adminId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); - @Query(value = """ - SELECT - p.id AS paperId, - p.store_id AS storeId, - s.name AS storeName, - CAST(COUNT(pu.id) AS UNSIGNED) AS usageCount - FROM paper p - JOIN store s ON s.id = p.store_id - JOIN paper_content pc ON pc.paper_id = p.id - JOIN partnership_usage pu ON pu.paper_id = pc.id - WHERE p.admin_id = :adminId - GROUP BY p.id, p.store_id, s.name - HAVING usageCount > 0 - ORDER BY usageCount DESC, p.id ASC - """, nativeQuery = true) + @Query(""" + SELECT new com.assu.server.domain.mapping.dto.StoreUsageWithPaper( + p.id, p.store.id, p.store.name, COUNT(pu.id) + ) + FROM PartnershipUsage pu + JOIN Paper p ON pu.paperId = p.id + WHERE p.admin.id = :adminId + GROUP BY p.id, p.store.id, p.store.name + ORDER BY COUNT(pu.id) DESC, p.id ASC +""") List findUsageByStoreWithPaper(@Param("adminId") Long adminId); - - // 0건 포함 조회 (대시보드에서 모든 제휴 업체를 보여줘야 하는 경우) - @Query(value = """ - SELECT - p.id AS paperId, - p.store_id AS storeId, - s.name AS storeName, - CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount - FROM paper p - JOIN store s ON s.id = p.store_id - LEFT JOIN paper_content pc ON pc.paper_id = p.id - LEFT JOIN partnership_usage pu ON pu.paper_id = pc.id - WHERE p.admin_id = :adminId - GROUP BY p.id, p.store_id, s.name - ORDER BY usageCount DESC, p.id ASC - """, nativeQuery = true) - List findUsageByStoreIncludingZero(@Param("adminId") Long adminId); - - interface StoreUsageWithPaper { - Long getPaperId(); // 🆕 추가: Paper ID - Long getStoreId(); - String getStoreName(); - Long getUsageCount(); - } -} \ No newline at end of file + } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java index 2eb03ee3..1db0d166 100644 --- a/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java +++ b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java @@ -2,19 +2,19 @@ import com.assu.server.domain.admin.entity.Admin; import com.assu.server.domain.admin.repository.AdminRepository; -import com.assu.server.domain.mapping.converter.StudentAdminConverter; +import com.assu.server.domain.mapping.dto.StoreUsageWithPaper; import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; import com.assu.server.domain.mapping.repository.StudentAdminRepository; import com.assu.server.domain.partnership.entity.Paper; import com.assu.server.domain.partnership.repository.PaperRepository; -import com.assu.server.domain.partnership.repository.PartnershipRepository; -import com.assu.server.domain.user.service.StudentService; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -33,25 +33,32 @@ public StudentAdminResponseDTO.CountAdminAuthResponseDTO getCountAdminAuth(Long Admin admin = getAdminOrThrow(memberId); Long total = studentAdminRepository.countAllByAdminId(memberId); - return StudentAdminConverter.countAdminAuthDTO(memberId, total, admin.getName()); + return StudentAdminResponseDTO.CountAdminAuthResponseDTO.from(memberId, total, admin.getName()); } @Override @Transactional public StudentAdminResponseDTO.NewCountAdminResponseDTO getNewStudentCountAdmin(Long memberId) { Admin admin = getAdminOrThrow(memberId); - Long total = studentAdminRepository.countThisMonthByAdminId(memberId); - return StudentAdminConverter.newCountAdminResponseDTO(memberId, total, admin.getName()); + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + Long total = studentAdminRepository.countTodayUsersByAdmin(memberId, startOfDay, endOfDay); + + return StudentAdminResponseDTO.NewCountAdminResponseDTO.from(memberId, total, admin.getName()); } @Override @Transactional public StudentAdminResponseDTO.CountUsagePersonResponseDTO getCountUsagePerson(Long memberId) { Admin admin = getAdminOrThrow(memberId); - Long total = studentAdminRepository.countTodayUsersByAdmin(memberId); - return StudentAdminConverter.countUsagePersonDTO(memberId, total, admin.getName()); + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + + Long total = studentAdminRepository.countTodayUsersByAdmin(memberId, startOfDay, endOfDay); + + return StudentAdminResponseDTO.CountUsagePersonResponseDTO.from(memberId, total, admin.getName()); } @Override @@ -59,21 +66,19 @@ public StudentAdminResponseDTO.CountUsagePersonResponseDTO getCountUsagePerson(L public StudentAdminResponseDTO.CountUsageResponseDTO getCountUsage(Long memberId) { Admin admin = getAdminOrThrow(memberId); - List storeUsages = + List storeUsages = studentAdminRepository.findUsageByStoreWithPaper(memberId); - //예외 처리 if (storeUsages.isEmpty()) { throw new DatabaseException(ErrorStatus.NO_USAGE_DATA); } - // 첫 번째가 가장 사용량이 많은 업체 (ORDER BY usageCount DESC) - var top = storeUsages.get(0); + StoreUsageWithPaper top = storeUsages.get(0); - Paper paper = paperRepository.findById(top.getPaperId()) + Paper paper = paperRepository.findById(top.paperId()) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE)); - return StudentAdminConverter.countUsageResponseDTO(admin, paper, top.getUsageCount()); + return StudentAdminResponseDTO.CountUsageResponseDTO.from(admin, paper, top.usageCount()); } @Override @@ -81,34 +86,31 @@ public StudentAdminResponseDTO.CountUsageResponseDTO getCountUsage(Long memberId public StudentAdminResponseDTO.CountUsageListResponseDTO getCountUsageList(Long memberId) { Admin admin = getAdminOrThrow(memberId); - // 🔧 핵심 수정: Paper 정보를 포함한 조회 (N+1 해결) - List storeUsages = + List storeUsages = studentAdminRepository.findUsageByStoreWithPaper(memberId); if (storeUsages.isEmpty()) { - // 빈 리스트 반환 (선택: 예외 처리도 가능) - return StudentAdminConverter.countUsageListResponseDTO(List.of()); + return StudentAdminResponseDTO.CountUsageListResponseDTO.from(List.of()); } List paperIds = storeUsages.stream() - .map(StudentAdminRepository.StoreUsageWithPaper::getPaperId) + .map(StoreUsageWithPaper::paperId) .toList(); Map paperMap = paperRepository.findAllById(paperIds).stream() .collect(Collectors.toMap(Paper::getId, paper -> paper)); - var items = storeUsages.stream().map(row -> { - Paper paper = paperMap.get(row.getPaperId()); + List items = storeUsages.stream().map(row -> { + Paper paper = paperMap.get(row.paperId()); if (paper == null) { throw new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE); } - return StudentAdminConverter.countUsageResponseDTO(admin, paper, row.getUsageCount()); + return StudentAdminResponseDTO.CountUsageResponseDTO.from(admin, paper, row.usageCount()); }).toList(); - return StudentAdminConverter.countUsageListResponseDTO(items); + return StudentAdminResponseDTO.CountUsageListResponseDTO.from(items); } - // Admin 조회 중복 제거 private Admin getAdminOrThrow(Long adminId) { return adminRepository.findById(adminId) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); diff --git a/src/main/java/com/assu/server/domain/partner/entity/Partner.java b/src/main/java/com/assu/server/domain/partner/entity/Partner.java index afb753e9..7477345b 100644 --- a/src/main/java/com/assu/server/domain/partner/entity/Partner.java +++ b/src/main/java/com/assu/server/domain/partner/entity/Partner.java @@ -47,7 +47,4 @@ public class Partner { private double latitude; private double longitude; - public void setMember(Member member) { - this.member = member; - } } diff --git a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java index ca5b4fd3..bbb02158 100644 --- a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java +++ b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java @@ -7,6 +7,7 @@ import com.assu.server.global.apiPayload.code.status.SuccessStatus; import com.assu.server.global.util.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -22,12 +23,15 @@ @RestController @RequiredArgsConstructor @RequestMapping("/reviews") +@Tag(name = "리뷰 관련 API", description = "리뷰 작성, 조회, 삭제 및 통계 관련 API") public class ReviewController { private final ReviewService reviewService; + @Operation( summary = "리뷰 작성 API", - description = "리뷰 내용과 별점, 리뷰 이미지를 입력해주세요." - + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2241197c19ed8176ba4fcb49c0136f93)\n" + + "- 리뷰 내용, 별점, 이미지를 멀티파트로 입력받아 저장합니다.\n" + + "- Authentication: JWT 토큰 필요 (Student 권한)" ) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public BaseResponse writeReview( @@ -40,7 +44,8 @@ public BaseResponse writeReview( @Operation( summary = "내가 쓴 리뷰 조회 API", - description = "Authorization 후에 사용해주세요." + description = "# [v1.0 (2025-09-02)](https://www.notion.so/22b1197c19ed8057b158c88f6153d073)\n" + + "- 로그인한 학생 사용자가 작성한 리뷰 목록을 페이징하여 조회합니다." ) @GetMapping("/student") public BaseResponse> checkStudent( @@ -50,59 +55,61 @@ public BaseResponse> checkStudent } @Operation( - summary = "내 가게 리뷰 조회 API", - description = "Authorization 후에 사용해주세요." + summary = "내 가게 리뷰 조회 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/_-2241197c19ed8130b89ad5a77f3e8b2c)\n" + + "- 로그인한 파트너 계정의 가게에 달린 리뷰 목록을 페이징하여 조회합니다." ) @GetMapping("/partner") public BaseResponse> checkPartnerReview( - @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable + @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable ){ return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkPartnerReview(pd.getId(), pageable)); } @Operation( - summary = "가게 리뷰 조회 API", - description = "storeId 기반으로 가게 리뷰를 조회하는 API 입니다." + summary = "가게 리뷰 조회 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2681197c19ed80038db3f7dd357623ff)\n" + + "- 특정 storeId를 기반으로 해당 가게의 모든 리뷰를 조회합니다." ) @GetMapping("/store/{storeId}") public BaseResponse> checkStoreReview( - Pageable pageable, @PathVariable Long storeId + Pageable pageable, @PathVariable Long storeId ){ return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkStoreReview(storeId, pageable)); } @Operation( summary = "내가 쓴 리뷰 삭제 API", - description = "삭제할 리뷰 ID를 입력해주세요." + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2241197c19ed81a58e93c9ba56f6cb9a)\n" + + "- 본인이 작성한 리뷰를 ID를 기반으로 삭제합니다." ) @DeleteMapping("/{reviewId}") public ResponseEntity> deleteReview( @PathVariable Long reviewId) { - return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.deleteReview(reviewId))); } @Operation( - summary = "store 리뷰 평균 조회 API", - description = "storeId 기반으로 조회하는 API 입니다." + summary = "가게 리뷰 평균 조회 API (ID 기반)", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2ef1197c19ed80a5a08fd4d2aa031e5f)\n" + + "- 특정 storeId 기반으로 해당 가게의 리뷰 평점을 조회합니다." ) @GetMapping("/average/{storeId}") public ResponseEntity> getStandardScore( - @PathVariable Long storeId + @PathVariable Long storeId ){ return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.standardScore(storeId))); } @Operation( - summary = "store 리뷰 평균 조회 API", - description = "partner 로그인 시 자신의 가게 평균을 조회하는 api 입니다." + summary = "내 가게 리뷰 평균 조회 API (파트너)", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/API-2681197c19ed80df9f2ac100812c7f44)\n" + + "- 파트너 로그인 시 본인 가게의 평균 평점을 조회합니다." ) @GetMapping("/average") public ResponseEntity> getMyStoreAverage( - @AuthenticationPrincipal PrincipalDetails pd + @AuthenticationPrincipal PrincipalDetails pd ){ return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.myStoreAverage(pd.getId()))); } - - -} +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java b/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java deleted file mode 100644 index 234247de..00000000 --- a/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.assu.server.domain.review.converter; - -import com.assu.server.domain.partner.entity.Partner; -import com.assu.server.domain.review.dto.ReviewRequestDTO; -import com.assu.server.domain.review.dto.ReviewResponseDTO; -import com.assu.server.domain.review.entity.Review; -import com.assu.server.domain.review.entity.ReviewPhoto; -import com.assu.server.domain.store.entity.Store; -import com.assu.server.domain.user.entity.Student; - -import java.util.stream.Collectors; - -import org.springframework.data.domain.Page; - -public class ReviewConverter { - public static ReviewResponseDTO.WriteReviewResponseDTO writeReviewResultDTO(Review review){ - //enti -> dto - return ReviewResponseDTO.WriteReviewResponseDTO.builder() - .reviewId(review.getId())// 리스폰스 dto로 아이디를 바꿔줄거다. - .rate(review.getRate()) - .content(review.getContent()) -// .memberId(review.getStudent().getId()) - .createdAt(review.getCreatedAt()) - .reviewImageUrls(review.getImageList().stream() - .map(ReviewPhoto::getPhotoUrl) - .collect(Collectors.toList())) - //한 리뷰 여러개 사진 but 하나로 묶임 추가 고려해보기 --추후에 !! - .build(); //리스폰스 리턴 - } - public static Review toReviewEntity(ReviewRequestDTO.WriteReviewRequestDTO request, Store store, Partner partner, Student student, String affiliation) { - //request - return Review.builder() - .rate(request.getRate()) - .content(request.getContent()) - .store(store) - .affiliation(affiliation) - .partner(partner) - .student(student) - // .imageList(request.getReviewImage()) - .build(); - } - public static ReviewResponseDTO.CheckReviewResponseDTO checkReviewResultDTO(Review review){ - return ReviewResponseDTO.CheckReviewResponseDTO.builder() - .reviewId(review.getId()) - .rate(review.getRate()) - .content(review.getContent()) - .createdAt(review.getCreatedAt()) - .storeName(review.getStore().getName()) - .affiliation(review.getAffiliation()) - .storeId(review.getStore().getId()) - .reviewImageUrls(review.getImageList().stream() - .map(ReviewPhoto::getPhotoUrl) - .collect(Collectors.toList())) - .build(); - } - // public static List checkStudentReviewResultDTO(List reviews){ - // return reviews.stream() - // .map(ReviewConverter::checkStudentReviewResultDTO) - // .collect(Collectors.toList()); - // } - - public static Page checkReviewResultDTO(Page reviews){ - return reviews.map(ReviewConverter::checkReviewResultDTO); - } - // - // public static ReviewResponseDTO.CheckPartnerReviewResponseDTO checkPartnerReviewResultDTO(Review review){ - // return ReviewResponseDTO.CheckPartnerReviewResponseDTO.builder() - // .reviewId(review.getId()) - // .storeId(review.getStore().getId()) - // .reviewerId(review.getStudent().getId()) - // .content(review.getContent()) - // .rate(review.getRate()) - // .createdAt(review.getCreatedAt()) - // .reviewImageUrls(review.getImageList().stream() - // .map(ReviewPhoto::getPhotoUrl) - // .collect(Collectors.toList())) - // .build(); - // - // } - // - // public static Page checkPartnerReviewResultDTO(Page reviews){ - // return reviews.map(ReviewConverter::checkPartnerReviewResultDTO); - // } - // public static ReviewResponseDTO.DeleteReviewResponseDTO deleteReviewResultDTO(Long reviewId){ - // return ReviewResponseDTO.DeleteReviewResponseDTO.builder() - // .reviewId(reviewId) - // .build(); - // } -} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java b/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java index e8db66ad..6c2df9eb 100644 --- a/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java +++ b/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java @@ -1,13 +1,16 @@ package com.assu.server.domain.review.dto; -import com.assu.server.domain.review.entity.ReviewPhoto; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.user.entity.Student; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import org.springframework.web.multipart.MultipartFile; import java.util.List; -public class ReviewRequestDTO { +public class ReviewRequestDTO { @Getter @Setter @Builder @@ -17,7 +20,7 @@ public static class WriteReviewRequestDTO { @Schema(description = "리뷰 내용", example = "정말 맛있었어요!") private String content; - @Schema(description = "별점 (1-10)", example = "5", minimum = "1", maximum = "10") + @Schema(description = "별점 (1-5)", example = "5", minimum = "1", maximum = "5") private Integer rate; @Schema(hidden = true) @@ -31,5 +34,16 @@ public static class WriteReviewRequestDTO { private Long partnershipUsageId; private String adminName; + + public Review toEntity(Store store, Partner partner, Student student, String affiliation) { + return Review.builder() + .rate(this.rate) + .content(this.content) + .store(store) + .partner(partner) + .student(student) + .affiliation(affiliation) + .build(); + } } } diff --git a/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java b/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java index 13e84b92..bcb58f52 100644 --- a/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java +++ b/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java @@ -1,69 +1,92 @@ package com.assu.server.domain.review.dto; -import lombok.AllArgsConstructor; +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.review.entity.ReviewPhoto; import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.List; public class ReviewResponseDTO { - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class WriteReviewResponseDTO { - private Long reviewId; //entity 보고 형 맞추기 - private String content; - private Integer rate; - private LocalDateTime createdAt; - private Long memberId; - private List reviewImageUrls; - } - @Getter - @NoArgsConstructor - @AllArgsConstructor + @Builder - public static class CheckReviewResponseDTO { //내가 작성한 리뷰 - private Long reviewId; - private Long storeId; - private String affiliation; // store 기준 조회시 필요... - private String storeName; - private String content; - private Integer rate; - private LocalDateTime createdAt; - private List reviewImageUrls; + public record WriteReviewResponseDTO( + Long reviewId, + String content, + Integer rate, + LocalDateTime createdAt, + Long memberId, + List reviewImageUrls + ) { + public static WriteReviewResponseDTO from(Review review) { + return WriteReviewResponseDTO.builder() + .reviewId(review.getId()) + .content(review.getContent()) + .rate(review.getRate()) + .createdAt(review.getCreatedAt()) + .memberId(review.getStudent().getId()) + .reviewImageUrls(review.getImageList().stream() + .map(ReviewPhoto::getPhotoUrl) + .toList()) + .build(); + } } - @Getter - @NoArgsConstructor - @AllArgsConstructor + @Builder - public static class CheckPartnerReviewResponseDTO {//partner의 리뷰 확인 - private Long reviewId; - private Long storeId; //현재 파트너의 가게 아이디 - private Long reviewerId; - private String content; - private Integer rate; - private LocalDateTime createdAt; - private List reviewImageUrls; + public record CheckReviewResponseDTO( + Long reviewId, + Long storeId, + String affiliation, + String storeName, + String content, + Integer rate, + LocalDateTime createdAt, + List reviewImageUrls + ) { + public static CheckReviewResponseDTO from(Review review) { + return CheckReviewResponseDTO.builder() + .reviewId(review.getId()) + .storeId(review.getStore().getId()) + .affiliation(review.getAffiliation()) + .storeName(review.getStore().getName()) + .content(review.getContent()) + .rate(review.getRate()) + .createdAt(review.getCreatedAt()) + .reviewImageUrls(review.getImageList().stream() + .map(ReviewPhoto::getPhotoUrl) + .toList()) + .build(); + } } - - @Getter - @NoArgsConstructor - @AllArgsConstructor @Builder - public static class DeleteReviewResponseDTO { - private Long reviewId; + public record CheckPartnerReviewResponseDTO( + Long reviewId, + Long storeId, + Long reviewerId, + String content, + Integer rate, + LocalDateTime createdAt, + List reviewImageUrls + ) { + public static CheckPartnerReviewResponseDTO from(Review review) { + return CheckPartnerReviewResponseDTO.builder() + .reviewId(review.getId()) + .storeId(review.getStore().getId()) + .reviewerId(review.getStudent().getId()) + .content(review.getContent()) + .rate(review.getRate()) + .createdAt(review.getCreatedAt()) + .reviewImageUrls(review.getImageList().stream() + .map(ReviewPhoto::getPhotoUrl) + .toList()) + .build(); + } } - @Getter - @NoArgsConstructor - @AllArgsConstructor @Builder - public static class StandardScoreResponseDTO { - private Float score; - } + public record DeleteReviewResponseDTO(Long reviewId) {} -} + @Builder + public record StandardScoreResponseDTO(Float score) {} +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/review/entity/Review.java b/src/main/java/com/assu/server/domain/review/entity/Review.java index b82f758e..d43546fc 100644 --- a/src/main/java/com/assu/server/domain/review/entity/Review.java +++ b/src/main/java/com/assu/server/domain/review/entity/Review.java @@ -49,13 +49,6 @@ public class Review extends BaseEntity { @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List imageList = new ArrayList<>(); - public List getImageList() { - if (imageList == null) { - imageList = new ArrayList<>(); - } - return imageList; - } - private Integer rate; private String content; @@ -65,7 +58,6 @@ public List getImageList() { @Builder.Default private ReportedStatus status = ReportedStatus.NORMAL; - // 상태 업데이트 메서드 public void updateReportedStatus(ReportedStatus status) { this.status = status; } diff --git a/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java b/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java index 892e70df..b0059148 100644 --- a/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java +++ b/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java @@ -1,16 +1,7 @@ package com.assu.server.domain.review.entity; -import com.assu.server.domain.common.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import com.assu.server.domain.common.entity.BaseEntity; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/assu/server/domain/review/exception/CustomReviewException.java b/src/main/java/com/assu/server/domain/review/exception/CustomReviewException.java new file mode 100644 index 00000000..5b3ab373 --- /dev/null +++ b/src/main/java/com/assu/server/domain/review/exception/CustomReviewException.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.review.exception; + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.exception.GeneralException; + +public class CustomReviewException extends GeneralException { + + public CustomReviewException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java b/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java index 8865e50c..aab2ae59 100644 --- a/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java +++ b/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java @@ -2,11 +2,11 @@ import com.assu.server.domain.partner.entity.Partner; import com.assu.server.domain.partner.repository.PartnerRepository; -import com.assu.server.domain.review.converter.ReviewConverter; import com.assu.server.domain.review.dto.ReviewRequestDTO; import com.assu.server.domain.review.dto.ReviewResponseDTO; import com.assu.server.domain.review.entity.Review; import com.assu.server.domain.review.entity.ReviewPhoto; +import com.assu.server.domain.review.exception.CustomReviewException; import com.assu.server.domain.review.repository.ReviewRepository; import com.assu.server.domain.store.entity.Store; import com.assu.server.domain.user.entity.PartnershipUsage; @@ -16,7 +16,6 @@ import com.assu.server.domain.user.repository.StudentRepository; import com.assu.server.domain.common.entity.enums.ReportedStatus; import com.assu.server.global.apiPayload.code.status.ErrorStatus; -import com.assu.server.global.exception.DatabaseException; import com.assu.server.global.exception.GeneralException; import com.assu.server.infra.s3.AmazonS3Manager; import jakarta.transaction.Transactional; @@ -43,17 +42,21 @@ public class ReviewServiceImpl implements ReviewService { private final PartnershipUsageRepository partnershipUsageRepository; @Override + @Transactional public ReviewResponseDTO.WriteReviewResponseDTO writeReview(ReviewRequestDTO.WriteReviewRequestDTO request, Long memberId, List reviewImages) { - // createReview 메서드 호출로 통합 String affiliation = adminNameToAffliation(request.getAdminName()); + Review review = createReview(memberId, request.getStoreId(), request, reviewImages, affiliation); + PartnershipUsage pu = partnershipUsageRepository.findById(request.getPartnershipUsageId()).orElseThrow( - () -> new GeneralException(ErrorStatus.NO_SUCH_USAGE) + () -> new GeneralException(ErrorStatus.NO_SUCH_USAGE) ); pu.setIsReviewed(true); partnershipUsageRepository.save(pu); + recalcAndUpdateStoreRate(review.getStore().getId()); - return ReviewConverter.writeReviewResultDTO(review); + + return ReviewResponseDTO.WriteReviewResponseDTO.from(review); } private String adminNameToAffliation(String adminName) { @@ -64,19 +67,16 @@ private String adminNameToAffliation(String adminName) { } private Review createReview(Long memberId, Long storeId, ReviewRequestDTO.WriteReviewRequestDTO request, List images, String affiliation) { - // 존재여부 검증 Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_STORE)); Partner partner = partnerRepository.findById(request.getPartnerId()) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_PARTNER)); Student student = studentRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_STUDENT)); - // 리뷰 엔티티 생성 및 저장 - Review review = ReviewConverter.toReviewEntity(request, store, partner, student, affiliation); - reviewRepository.save(review); // ID 생성을 위해 먼저 저장 + Review review = request.toEntity(store, partner, student, affiliation); + reviewRepository.save(review); - // 이미지 처리 if (images != null && !images.isEmpty()) { try { for (int i = 0; i < images.size(); i++) { @@ -93,7 +93,7 @@ private Review createReview(Long memberId, Long storeId, ReviewRequestDTO.WriteR review.getImageList().add(reviewPhoto); } } catch (Exception e) { - throw new DatabaseException(ErrorStatus.IMAGE_UPLOAD_FAILED); + throw new CustomReviewException(ErrorStatus.IMAGE_UPLOAD_FAILED); } } @@ -105,12 +105,12 @@ private String generateReviewImageKeyName(Long memberId, Long reviewId, int imag String year = String.valueOf(now.getYear()); String month = String.format("%02d", now.getMonthValue()); - // 기존 generateKeyName 방식을 참고하되 더 체계적으로 return String.format("reviews/images/%s/%s/user%d/review%d_img%d_%s", year, month, memberId, reviewId, imageIndex, UUID.randomUUID()); } @Override + @Transactional public Page checkStudentReview(Long memberId, Pageable pageable) { pageable = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize(), pageable.getSort()); Page reviews = reviewRepository.findByMemberId(memberId, pageable); @@ -119,7 +119,7 @@ public Page checkStudentReview(Long me updateReviewImageUrls(review); } - return ReviewConverter.checkReviewResultDTO(reviews); + return reviews.map(ReviewResponseDTO.CheckReviewResponseDTO::from); } @Override @@ -127,44 +127,40 @@ public Page checkStudentReview(Long me public Page checkPartnerReview(Long memberId, Pageable pageable) { pageable = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize(), pageable.getSort()); Partner partner = partnerRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_PARTNER)); Store store = storeRepository.findByPartner(partner) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_STORE)); - // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만 조회 Page reviews = reviewRepository.findByStoreIdAndStatusAndStudentStatus( - store.getId(), - ReportedStatus.NORMAL, - ReportedStatus.NORMAL, - pageable); + store.getId(), ReportedStatus.NORMAL, ReportedStatus.NORMAL, pageable); for (Review review : reviews) { updateReviewImageUrls(review); } - return ReviewConverter.checkReviewResultDTO(reviews); + return reviews.map(ReviewResponseDTO.CheckReviewResponseDTO::from); } @Override @Transactional public ReviewResponseDTO.DeleteReviewResponseDTO deleteReview(Long reviewId) { Review review = reviewRepository.findById(reviewId) - .orElseThrow(() -> new DatabaseException(ErrorStatus._BAD_REQUEST)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus._BAD_REQUEST)); Long storeId = review.getStore().getId(); + reviewRepository.deleteById(reviewId); + recalcAndUpdateStoreRate(storeId); - reviewRepository.deleteById(reviewId); return ReviewResponseDTO.DeleteReviewResponseDTO.builder() - .reviewId(reviewId) - .build(); + .reviewId(reviewId) + .build(); } private void updateReviewImageUrls(Review review) { for (ReviewPhoto reviewPhoto : review.getImageList()) { if (reviewPhoto.getKeyName() != null) { String freshUrl = amazonS3Manager.generatePresignedUrl(reviewPhoto.getKeyName()); - // ReviewPhoto 엔티티에 URL 업데이트 (일시적으로, DB에는 저장하지 않음) reviewPhoto.updatePhotoUrl(freshUrl); } } @@ -175,71 +171,45 @@ private void updateReviewImageUrls(Review review) { public Page checkStoreReview(Long storeId, Pageable pageable) { pageable = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize(), pageable.getSort()); Store store = storeRepository.findById(storeId).orElseThrow( - () -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + () -> new CustomReviewException(ErrorStatus.NO_SUCH_STORE)); - // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만 조회 Page reviews = reviewRepository.findByStoreIdAndStatusAndStudentStatus( - store.getId(), - ReportedStatus.NORMAL, - ReportedStatus.NORMAL, - pageable); + store.getId(), ReportedStatus.NORMAL, ReportedStatus.NORMAL, pageable); for (Review review : reviews) { updateReviewImageUrls(review); } - return ReviewConverter.checkReviewResultDTO(reviews); + return reviews.map(ReviewResponseDTO.CheckReviewResponseDTO::from); } @Override @Transactional public ReviewResponseDTO.StandardScoreResponseDTO standardScore(Long storeId) { - // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만으로 평균 계산 Float score = reviewRepository.standardScoreWithStatus(storeId, ReportedStatus.NORMAL, ReportedStatus.NORMAL); - if(score == null){ - score = 0f; - } - return ReviewResponseDTO.StandardScoreResponseDTO.builder() - .score(score) - .build(); + return new ReviewResponseDTO.StandardScoreResponseDTO(score == null ? 0f : score); } @Override @Transactional public ReviewResponseDTO.StandardScoreResponseDTO myStoreAverage(Long memberId) { Partner partner = partnerRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_PARTNER)); Store store = storeRepository.findByPartner(partner) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); - - // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만으로 평균 계산 - Float score = reviewRepository.standardScoreWithStatus(store.getId(), ReportedStatus.NORMAL, - ReportedStatus.NORMAL); - if (score == null) { - score = 0f; - } - System.out.println(store.getId()); - return ReviewResponseDTO.StandardScoreResponseDTO - .builder() - .score(score) - .build(); + .orElseThrow(() -> new CustomReviewException(ErrorStatus.NO_SUCH_STORE)); + Float score = reviewRepository.standardScoreWithStatus(store.getId(), ReportedStatus.NORMAL, ReportedStatus.NORMAL); + return new ReviewResponseDTO.StandardScoreResponseDTO(score == null ? 0f : score); } private void recalcAndUpdateStoreRate(Long storeId) { - // 이 시점에 영속성 컨텍스트의 변경분을 DB로 내보내 평균에 반영 reviewRepository.flush(); - - Float avg = reviewRepository.standardScoreWithStatus( - storeId, ReportedStatus.NORMAL, ReportedStatus.NORMAL - ); - if (avg == null) avg = 0f; - - int rounded = (int) (Math.round(avg * 10f) / 10f); + Float avg = reviewRepository.standardScoreWithStatus(storeId, ReportedStatus.NORMAL, ReportedStatus.NORMAL); + int rounded = (avg == null) ? 0 : (int) (Math.round(avg * 10f) / 10f); storeRepository.findById(storeId).ifPresent(s -> { s.setRate(rounded); storeRepository.save(s); }); } -} +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java index 7d5f12e9..42373eb2 100644 --- a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java +++ b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java @@ -3,7 +3,7 @@ import java.util.List; public class StoreResponseDTO { - + public record WeeklyRankResponseDTO( Long rank, // 그 주 순위(1부터) Long usageCount // 그 주 사용 건수 diff --git a/src/main/java/com/assu/server/domain/store/exception/CustomStoreException.java b/src/main/java/com/assu/server/domain/store/exception/CustomStoreException.java new file mode 100644 index 00000000..2518573a --- /dev/null +++ b/src/main/java/com/assu/server/domain/store/exception/CustomStoreException.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.store.exception; + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.exception.GeneralException; + +public class CustomStoreException extends GeneralException { + + public CustomStoreException(BaseErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java index aabeb715..a0789d8b 100644 --- a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java @@ -1,6 +1,4 @@ package com.assu.server.domain.store.repository; - - import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import com.assu.server.domain.store.entity.Store; diff --git a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java index 049cce14..892e4107 100644 --- a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java +++ b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.stream.Collectors; +import com.assu.server.domain.store.exception.CustomStoreException; import org.springframework.stereotype.Service; import com.assu.server.domain.store.dto.StoreResponseDTO; import com.assu.server.domain.store.dto.TodayBestResponseDTO; @@ -17,7 +18,6 @@ import com.assu.server.domain.store.converter.StoreConverter; import com.assu.server.domain.store.entity.Store; import com.assu.server.global.apiPayload.code.status.ErrorStatus; -import com.assu.server.global.exception.DatabaseException; import java.util.Optional; @Service @@ -41,7 +41,7 @@ public StoreResponseDTO.WeeklyRankResponseDTO getWeeklyRank(Long memberId) { Optional partner = partnerRepository.findById(memberId); Store store = storeRepository.findByPartner(partner.orElse(null)) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + .orElseThrow(() -> new CustomStoreException(ErrorStatus.NO_SUCH_STORE)); Long storeId = store.getId(); List rows = storeRepository.findGlobalWeeklyRankForStore(storeId); @@ -58,7 +58,7 @@ public StoreResponseDTO.ListWeeklyRankResponseDTO getListWeeklyRank(Long memberI Optional partner = partnerRepository.findById(memberId); Store store = storeRepository.findByPartner(partner.orElse(null)) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + .orElseThrow(() -> new CustomStoreException(ErrorStatus.NO_SUCH_STORE)); Long storeId = store.getId(); List rows = storeRepository.findGlobalWeeklyTrendLast6Weeks(storeId); @@ -72,14 +72,14 @@ public StoreResponseDTO.ListWeeklyRankResponseDTO getListWeeklyRank(Long memberI @Transactional public StoreResponseDTO.StampRankingListDTO getStampRanking() { List rows = qrCertificationRepository.findDailyStampRanking(); - + List rankings = rows.stream() .map(row -> new StoreResponseDTO.StampRankingDTO( row.getStoreId(), row.getStoreName(), row.getStampCount())) .collect(Collectors.toList()); - + return new StoreResponseDTO.StampRankingListDTO(rankings); } } diff --git a/src/main/java/com/assu/server/domain/user/controller/StudentController.java b/src/main/java/com/assu/server/domain/user/controller/StudentController.java index 9a92d18a..f2fc14e9 100644 --- a/src/main/java/com/assu/server/domain/user/controller/StudentController.java +++ b/src/main/java/com/assu/server/domain/user/controller/StudentController.java @@ -89,6 +89,18 @@ public BaseResponse getStamp( ) { return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getStamp(pd.getId())); } + @Operation( + summary = "스탬프 적립 및 이벤트 응모 API", + description = "# [v1.0 (2026-02-23)](https://clumsy-seeder-416.notion.site/3101197c19ed80b5b47eceb202535469)\n" + + "- 스탬프가 10개가 되는 시점에 자동으로 응모및 알림" + ) + @PostMapping("/stamp") + public BaseResponse earnStamp( + @AuthenticationPrincipal PrincipalDetails pd + ) { + studentService.addStamp(pd.getId()); + return BaseResponse.onSuccess(SuccessStatus._OK, "스탬프 적립 성공"); + } @Operation( summary = "사용자의 이용 가능한 제휴 조회 API", diff --git a/src/main/java/com/assu/server/domain/user/entity/StampEventApplicant.java b/src/main/java/com/assu/server/domain/user/entity/StampEventApplicant.java new file mode 100644 index 00000000..b57bcd40 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/StampEventApplicant.java @@ -0,0 +1,26 @@ +package com.assu.server.domain.user.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class StampEventApplicant extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id") + private Student student; + + private LocalDateTime appliedAt; + + // 회차 관리 (예: "2026_SEASON_1") + private String eventVersion; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/user/entity/Student.java b/src/main/java/com/assu/server/domain/user/entity/Student.java index 0c1b034d..4d0522c7 100644 --- a/src/main/java/com/assu/server/domain/user/entity/Student.java +++ b/src/main/java/com/assu/server/domain/user/entity/Student.java @@ -19,6 +19,7 @@ public class Student { @Id private Long id; + @Setter @OneToOne @JoinColumn(name = "id") // member_id와 공유 @MapsId @@ -46,13 +47,10 @@ public class Student { @Builder.Default private ReportedStatus status = ReportedStatus.NORMAL; - public void setMember(Member member) { - this.member = member; - } - public void setStamp() { this.stamp++; } + public void resetStamp() {this.stamp = 0;} /** * 유세인트에서 크롤링한 최신 정보로 학생 정보를 업데이트합니다. diff --git a/src/main/java/com/assu/server/domain/user/repository/StampEventApplicantRepository.java b/src/main/java/com/assu/server/domain/user/repository/StampEventApplicantRepository.java new file mode 100644 index 00000000..62042525 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/repository/StampEventApplicantRepository.java @@ -0,0 +1,9 @@ +package com.assu.server.domain.user.repository; + +import com.assu.server.domain.user.entity.StampEventApplicant; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface StampEventApplicantRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/user/service/StudentService.java b/src/main/java/com/assu/server/domain/user/service/StudentService.java index dd671fc6..37578cd1 100644 --- a/src/main/java/com/assu/server/domain/user/service/StudentService.java +++ b/src/main/java/com/assu/server/domain/user/service/StudentService.java @@ -13,4 +13,5 @@ public interface StudentService { Page getUnreviewedUsage(Long memberId, Pageable pageable); List getUsablePartnership(Long memberId, Boolean all); void syncUserPapersForAllStudents(); + StudentResponseDTO.CheckStampResponseDTO addStamp(Long id); } diff --git a/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java b/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java index 5b68e1e8..0883d304 100644 --- a/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java +++ b/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java @@ -5,6 +5,9 @@ import java.time.format.DateTimeFormatter; import java.util.List; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.user.entity.StampEventApplicant; +import com.assu.server.domain.user.repository.StampEventApplicantRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -42,10 +45,11 @@ public class StudentServiceImpl implements StudentService { private final UserPaperRepository userPaperRepository; private final PaperContentRepository paperContentRepository; private final PartnershipUsageRepository partnershipUsageRepository; + private final StampEventApplicantRepository stampEventApplicantRepository; private final GoodsRepository goodsRepository; private final AdminRepository adminRepository; private final PaperRepository paperRepository; - + private final NotificationCommandService notificationCommandService; @Override @Transactional public StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId) { @@ -111,14 +115,11 @@ public Page getUnreviewedUsage(Long memberId, Pa partnershipUsageRepository.findByUnreviewedUsage(memberId, pageable); return contentList.map(u -> { - // 1. partnershipUsage의 paperContentId 로 paperContent 조회 PaperContent paperContent = paperContentRepository.findById(u.getContentId()) .orElse(null); - // 2. store 추출 Store store = (paperContent != null) ? paperContent.getPaper().getStore() : null; - // 3. 날짜 포맷팅 LocalDateTime ld = u.getCreatedAt(); String formatDate = ld.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); @@ -148,7 +149,6 @@ public List getUsablePartnership(Long m String adminName = (paper.getAdmin() != null) ? paper.getAdmin().getName() : null; String partnerName = (store != null) ? store.getName() : null; - // 카테고리 결정 로직 그대로 String finalCategory = null; String note = null; if (content != null) { @@ -226,6 +226,32 @@ public void syncUserPapersForStudent(Long studentId) { userPaperRepository.save(up); } } + @Transactional + public StudentResponseDTO.CheckStampResponseDTO addStamp(Long memberId) { + Student student = studentRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + student.setStamp(); + String responseMessage = "스탬프가 적립되었습니다."; + + if (student.getStamp() >= 10) { + StampEventApplicant applicant = StampEventApplicant.builder() + .student(student) + .appliedAt(LocalDateTime.now()) + .eventVersion("2026_SEASON_1") + .build(); + stampEventApplicantRepository.save(applicant); + notificationCommandService.sendStamp(memberId); + + student.resetStamp(); + responseMessage = "스탬프 10개를 모아 자동 응모 되었습니다."; + } + return StudentResponseDTO.CheckStampResponseDTO.builder() + .userId(student.getId()) + .stamp(student.getStamp()) + .message(responseMessage) + .build(); + } /** * 전체 학생에 대해 일괄로 user_paper 채워 넣는 메서드