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
1 change: 1 addition & 0 deletions docs/reference/access-control-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 좋아요 수 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ List<CareFacility> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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> typeStats = careFacilityRepository.getTypeStats();

java.util.Map<String, Long> 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();
}

Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/carecode/domain/health/app/HealthFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,27 @@ public List<HospitalInfoResponse> getHospitalsByType(String type) {
.toList();
}

@Transactional(readOnly = true)
public com.carecode.domain.health.dto.response.HospitalStatsResponse getHospitalStats() {
java.util.Map<String, Long> 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<String, Long> sorted = new java.util.LinkedHashMap<>();
byType.entrySet().stream()
.sorted(java.util.Map.Entry.<String, Long>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<HospitalInfoResponse> getPopularHospitals(int limit) {
int safeLimit = Math.max(limit, 1);
return hospitalRepository.findPopularHospitals(PageRequest.of(0, safeLimit)).stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@ public ResponseEntity<HospitalLikeStatusResponse> getHospitalLikeStatus(
.build());
}

// 병원 수집 현황
// 소개 사이트가 수치를 자동으로 가져간다. 클래스 레벨 isAuthenticated() 를 덮는다.
// 경로는 /hospitals/{id} 와 모양이 같지만 Spring 은 리터럴 경로를 먼저 고른다.
@PreAuthorize("permitAll()")
@GetMapping("/hospitals/statistics")
@LogExecutionTime
@Operation(summary = "병원 수집 현황", description = "전체 병원 수와 진료과목별 병원 수")
public ResponseEntity<com.carecode.domain.health.dto.response.HospitalStatsResponse> getHospitalStatistics() {
return ResponseEntity.ok(healthFacade.getHospitalStats());
}

// 인기 병원 조회
// 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다.
@PreAuthorize("permitAll()")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.carecode.domain.health.dto.response;

import lombok.Builder;
import lombok.Getter;

import java.util.Map;

/**
* 병원 수집 현황. 소개 사이트가 "몇 곳을 모았는지" 를 자동으로 가져가는 데 쓴다.
*
* <p>목록 API 는 페이지 상한이 있어 세면 실제보다 적게 나온다. 그래서 집계를 따로 준다.
*/
@Getter
@Builder
public class HospitalStatsResponse {

private final long totalHospitals;

/** 진료과목(종별) → 병원 수. 값이 비어 있는 병원은 "기타" 로 묶는다. 많은 순. */
private final Map<String, Long> byType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ public interface HospitalRepository extends JpaRepository<Hospital, Long> {
ORDER BY COUNT(hl.id) DESC
""")
List<Hospital> findPopularHospitals(Pageable pageable);
}

/** 진료과목별 병원 수. [type, count] */
@Query("SELECT h.type, COUNT(h) FROM Hospital h GROUP BY h.type")
List<Object[]> countByType();
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class AccessControlContractTest {
"/facilities/statistics",
"/health/hospitals",
"/health/hospitals/popular",
"/health/hospitals/statistics",
"/community/posts",
"/community/tags"
})
Expand Down
126 changes: 126 additions & 0 deletions src/test/java/com/carecode/integration/PublicStatsContractTest.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading