From 5ce099f541278386162008e58984d14ecf9a6613 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 22 Sep 2026 20:49:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=9C=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=EB=A1=9C=20=EC=86=8C=EA=B0=9C=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=B9=98=EB=A5=BC=20=EC=9E=90=EB=8F=99=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /health/hospitals/statistics (공개): 전체 병원 수 + 진료과목별 수. 목록 API 는 페이지 상한이 있어 세면 실제보다 적게 나왔다. - 시설 통계가 유형별 집계를 조회해 놓고 버린 뒤 typeDistribution/typeStats 에 null, activeFacilities·예약 수에 0 을 넣던 것을 실제 값으로 채운다. --- docs/reference/access-control-matrix.md | 1 + .../repository/CareFacilityRepository.java | 2 + .../service/CareFacilityService.java | 29 ++-- .../domain/health/app/HealthFacade.java | 21 +++ .../health/controller/HealthController.java | 11 ++ .../dto/response/HospitalStatsResponse.java | 21 +++ .../health/repository/HospitalRepository.java | 6 +- .../AccessControlContractTest.java | 1 + .../integration/PublicStatsContractTest.java | 126 ++++++++++++++++++ 9 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java create mode 100644 src/test/java/com/carecode/integration/PublicStatsContractTest.java diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index 2d33beba..445c0e1a 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -92,6 +92,7 @@ flowchart TD | `/health/hospitals/{id}` | GET | 상세 | | `/health/hospitals/nearby` | GET | 반경 검색 | | `/health/hospitals/popular` | GET | 인기 | +| `/health/hospitals/statistics` | GET | 수집 현황(총계·진료과목별). 소개 사이트가 자동으로 가져간다 | | `/health/hospitals/type/{type}` | GET | 진료과목별 | | `/health/hospitals/{id}/reviews` | GET | 리뷰 조회 | | `/health/hospitals/{id}/likes` | GET | 좋아요 수 | diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java index 69cecbf7..08be12c7 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java @@ -85,6 +85,8 @@ List searchFacilities(@Param("facilityType") FacilityType facility @Param("maxTuitionFee") Integer maxTuitionFee, @Param("childAge") Integer childAge); + long countByIsActiveTrue(); + // 전체 조회수 합계 조회 @Query("SELECT COALESCE(SUM(cf.viewCount), 0) FROM CareFacility cf WHERE cf.isActive = true") long getTotalViewCount(); diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index 4b1586ac..b8c24836 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -45,6 +45,7 @@ public class CareFacilityService { private final CareFacilityRepository careFacilityRepository; + private final com.carecode.domain.careFacility.repository.CareFacilityBookingRepository bookingRepository; private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final CareFacilityMapper careFacilityMapper; @@ -408,19 +409,25 @@ public void updateRating(Long facilityId, Double rating) { // 돌봄 시설 통계 조회 @LogExecutionTime public CareFacilityStatsResponse getFacilityStats() { - long totalFacilities = careFacilityRepository.count(); - long totalViews = careFacilityRepository.getTotalViewCount(); + // 예전에는 유형별 통계를 조회해 놓고 버린 뒤 null 을, 활성 시설 수와 예약 수는 0 을 넣었다. + // 필드가 있으면 클라이언트는 값이 온다고 믿으므로(소개 사이트가 이 값을 그대로 보여 준다) 실제 값을 채운다. List typeStats = careFacilityRepository.getTypeStats(); - + java.util.Map typeDistribution = new java.util.LinkedHashMap<>(); + for (TypeStats stats : typeStats) { + if (stats.getFacilityType() != null) { + typeDistribution.put(stats.getFacilityType().name(), stats.getCount()); + } + } + return CareFacilityStatsResponse.builder() - .totalFacilities(totalFacilities) - .totalBookings(0L) - .activeFacilities(0L) - .typeDistribution(null) - .typeStats(null) - .todayBookings(0L) - .thisWeekBookings(0L) - .thisMonthBookings(0L) + .totalFacilities(careFacilityRepository.count()) + .activeFacilities(careFacilityRepository.countByIsActiveTrue()) + .typeDistribution(typeDistribution) + .typeStats(typeStats) + .totalBookings(bookingRepository.count()) + .todayBookings(bookingRepository.countTodayBookings()) + .thisWeekBookings(bookingRepository.countThisWeekBookings()) + .thisMonthBookings(bookingRepository.countThisMonthBookings()) .build(); } diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index 331439eb..f390fd06 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -235,6 +235,27 @@ public List getHospitalsByType(String type) { .toList(); } + @Transactional(readOnly = true) + public com.carecode.domain.health.dto.response.HospitalStatsResponse getHospitalStats() { + java.util.Map byType = new java.util.HashMap<>(); + long total = 0; + for (Object[] row : hospitalRepository.countByType()) { + String type = row[0] == null || row[0].toString().isBlank() ? "기타" : row[0].toString(); + long count = ((Number) row[1]).longValue(); + byType.merge(type, count, Long::sum); + total += count; + } + java.util.Map sorted = new java.util.LinkedHashMap<>(); + byType.entrySet().stream() + .sorted(java.util.Map.Entry.comparingByValue().reversed() + .thenComparing(java.util.Map.Entry.comparingByKey())) + .forEach(e -> sorted.put(e.getKey(), e.getValue())); + return com.carecode.domain.health.dto.response.HospitalStatsResponse.builder() + .totalHospitals(total) + .byType(sorted) + .build(); + } + public List getPopularHospitals(int limit) { int safeLimit = Math.max(limit, 1); return hospitalRepository.findPopularHospitals(PageRequest.of(0, safeLimit)).stream() diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index 18e45732..2629f27d 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -311,6 +311,17 @@ public ResponseEntity getHospitalLikeStatus( .build()); } + // 병원 수집 현황 + // 소개 사이트가 수치를 자동으로 가져간다. 클래스 레벨 isAuthenticated() 를 덮는다. + // 경로는 /hospitals/{id} 와 모양이 같지만 Spring 은 리터럴 경로를 먼저 고른다. + @PreAuthorize("permitAll()") + @GetMapping("/hospitals/statistics") + @LogExecutionTime + @Operation(summary = "병원 수집 현황", description = "전체 병원 수와 진료과목별 병원 수") + public ResponseEntity getHospitalStatistics() { + return ResponseEntity.ok(healthFacade.getHospitalStats()); + } + // 인기 병원 조회 // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. @PreAuthorize("permitAll()") diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java new file mode 100644 index 00000000..7da16888 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java @@ -0,0 +1,21 @@ +package com.carecode.domain.health.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +/** + * 병원 수집 현황. 소개 사이트가 "몇 곳을 모았는지" 를 자동으로 가져가는 데 쓴다. + * + *

목록 API 는 페이지 상한이 있어 세면 실제보다 적게 나온다. 그래서 집계를 따로 준다. + */ +@Getter +@Builder +public class HospitalStatsResponse { + + private final long totalHospitals; + + /** 진료과목(종별) → 병원 수. 값이 비어 있는 병원은 "기타" 로 묶는다. 많은 순. */ + private final Map byType; +} diff --git a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java index 1c0638cb..57e49cec 100644 --- a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java @@ -27,4 +27,8 @@ public interface HospitalRepository extends JpaRepository { ORDER BY COUNT(hl.id) DESC """) List findPopularHospitals(Pageable pageable); -} \ No newline at end of file + + /** 진료과목별 병원 수. [type, count] */ + @Query("SELECT h.type, COUNT(h) FROM Hospital h GROUP BY h.type") + List countByType(); +} diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index b02025d9..70ebd64d 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -99,6 +99,7 @@ class AccessControlContractTest { "/facilities/statistics", "/health/hospitals", "/health/hospitals/popular", + "/health/hospitals/statistics", "/community/posts", "/community/tags" }) diff --git a/src/test/java/com/carecode/integration/PublicStatsContractTest.java b/src/test/java/com/carecode/integration/PublicStatsContractTest.java new file mode 100644 index 00000000..44b5f086 --- /dev/null +++ b/src/test/java/com/carecode/integration/PublicStatsContractTest.java @@ -0,0 +1,126 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.health.entity.Hospital; +import com.carecode.domain.health.repository.HospitalRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 소개 사이트가 로그인 없이 가져가는 수집 현황. 필드가 있으면 값도 있어야 한다. + * 시설 통계는 유형별 집계를 조회해 놓고 null 을, 활성 시설 수는 0 을 내보내고 있었다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_public_stats;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("공개 통계") +class PublicStatsContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired CareFacilityRepository careFacilityRepository; + @Autowired HospitalRepository hospitalRepository; + + @Test + @DisplayName("시설 통계는 유형별 분포와 활성 시설 수를 실제 값으로 준다") + void facilityStatisticsAreFilled() throws Exception { + saveFacility(FacilityType.DAYCARE, true); + saveFacility(FacilityType.DAYCARE, true); + saveFacility(FacilityType.KINDERGARTEN, true); + saveFacility(FacilityType.KINDERGARTEN, false); + + JsonNode stats = getJson("/facilities/statistics"); + + assertThat(stats.path("totalFacilities").asLong()).isGreaterThanOrEqualTo(4); + assertThat(stats.path("activeFacilities").asLong()).isGreaterThanOrEqualTo(3) + .isLessThan(stats.path("totalFacilities").asLong()); + assertThat(stats.path("typeDistribution").path("DAYCARE").asLong()).isGreaterThanOrEqualTo(2); + assertThat(stats.path("typeStats").isArray()).isTrue(); + assertThat(stats.path("typeStats")).isNotEmpty(); + } + + @Test + @DisplayName("병원 통계는 로그인 없이 전체 수와 진료과목별 수를 준다") + void hospitalStatistics() throws Exception { + saveHospital("소아청소년과"); + saveHospital("소아청소년과"); + saveHospital(null); + + JsonNode stats = getJson("/health/hospitals/statistics"); + + assertThat(stats.path("totalHospitals").asLong()).isGreaterThanOrEqualTo(3); + assertThat(stats.path("byType").path("소아청소년과").asLong()).isGreaterThanOrEqualTo(2); + assertThat(stats.path("byType").path("기타").asLong()).isGreaterThanOrEqualTo(1); + } + + private JsonNode getJson(String path) throws Exception { + MvcResult result = mockMvc.perform(get(path)).andReturn(); + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(result.getResponse().getStatus()).as(body).isEqualTo(200); + return objectMapper.readTree(body); + } + + private void saveFacility(FacilityType type, boolean active) { + careFacilityRepository.save(CareFacility.builder() + .facilityCode("F-" + UUID.randomUUID()) + .name("시설") + .facilityType(type) + .isActive(active) + .build()); + } + + private void saveHospital(String type) { + hospitalRepository.save(Hospital.builder() + .name("병원-" + UUID.randomUUID()) + .type(type) + .build()); + } +}