diff --git a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df index 8d3c743..a5c4af1 100644 --- a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df +++ b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df @@ -27,13 +27,13 @@ Field calls method in (AdminCommunityController.java:46) Method calls method in (AdminCommunityController.java:51) Method calls method in (AdminCommunityController.java:33) -Method calls method in (AdminDashboardController.java:43) -Method calls method in (AdminDashboardController.java:44) -Method calls method in (AdminDashboardController.java:42) -Method calls method in (AdminDashboardController.java:64) -Method calls method in (AdminDashboardController.java:71) -Method calls method in (AdminDashboardController.java:57) -Method calls method in (AdminDashboardController.java:91) +Method calls method in (AdminDashboardController.java:46) +Method calls method in (AdminDashboardController.java:47) +Method calls method in (AdminDashboardController.java:45) +Method calls method in (AdminDashboardController.java:59) +Method calls method in (AdminDashboardController.java:62) +Method calls method in (AdminDashboardController.java:56) +Method calls method in (AdminDashboardController.java:78) Method calls method in (AdminHealthController.java:46) Method calls method in (AdminHealthController.java:51) Method calls method in (AdminHealthController.java:33) @@ -47,15 +47,15 @@ Method calls method in (AdminNotificationController.java:38) Method calls method in (AdminPolicyController.java:77) Method calls method in (AdminPolicyController.java:36) -Method calls method in (AdminPolicyVerificationController.java:73) -Method calls method in (AdminPolicyVerificationController.java:61) -Method calls method in (AdminPolicyVerificationController.java:65) -Method calls method in (AdminPolicyVerificationController.java:41) -Method calls method in (AdminPolicyVerificationController.java:47) +Method calls method in (AdminPolicyVerificationController.java:69) +Method calls method in (AdminPolicyVerificationController.java:57) +Method calls method in (AdminPolicyVerificationController.java:61) +Method calls method in (AdminPolicyVerificationController.java:42) +Method calls method in (AdminPolicyVerificationController.java:48) Method calls method in (AdminUserController.java:172) Method calls method in (AdminUserController.java:177) Method calls method in (AdminUserController.java:56) Method calls method in (AdminUserController.java:134) Method calls method in (NotificationLinkController.java:56) Method calls method in (NotificationLinkController.java:64) -Method calls method in (BenefitLinkController.java:43) +Method calls method in (BenefitLinkController.java:44) diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index db6675b..59ef0a7 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -39,8 +39,8 @@ admin → health 리포지토리: Constructor has type in (AdminDashboardController.java:0) admin → health 리포지토리: Field has type in (AdminHealthController.java:0) admin → health 리포지토리: Field has type in (AdminHospitalController.java:0) -admin → health 리포지토리: Method calls method in (AdminDashboardController.java:43) -admin → health 리포지토리: Method calls method in (AdminDashboardController.java:64) +admin → health 리포지토리: Method calls method in (AdminDashboardController.java:46) +admin → health 리포지토리: Method calls method in (AdminDashboardController.java:59) admin → health 리포지토리: Method calls method in (AdminHealthController.java:46) admin → health 리포지토리: Method calls method in (AdminHealthController.java:51) admin → health 리포지토리: Method calls method in (AdminHealthController.java:33) @@ -63,15 +63,15 @@ admin → policy 리포지토리: Field has type in (AdminPolicyVerificationController.java:0) admin → policy 리포지토리: Field has type in (PolicyAdminService.java:0) admin → policy 리포지토리: Field has type in (PolicyAdminService.java:0) -admin → policy 리포지토리: Method calls method in (AdminDashboardController.java:44) -admin → policy 리포지토리: Method calls method in (AdminDashboardController.java:71) +admin → policy 리포지토리: Method calls method in (AdminDashboardController.java:47) +admin → policy 리포지토리: Method calls method in (AdminDashboardController.java:62) admin → policy 리포지토리: Method calls method in (AdminPolicyController.java:77) admin → policy 리포지토리: Method calls method in (AdminPolicyController.java:36) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:73) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:61) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:65) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:41) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:47) +admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:69) +admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:57) +admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:61) +admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:42) +admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:48) admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:145) admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:44) admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:170) @@ -89,9 +89,9 @@ admin → user 리포지토리: Field has type in (AdminNotificationController.java:0) admin → user 리포지토리: Field has type in (AdminUserController.java:0) admin → user 리포지토리: Field has type in (CareFacilityBookingAdminService.java:0) -admin → user 리포지토리: Method calls method in (AdminDashboardController.java:42) -admin → user 리포지토리: Method calls method in (AdminDashboardController.java:57) -admin → user 리포지토리: Method calls method in (AdminDashboardController.java:91) +admin → user 리포지토리: Method calls method in (AdminDashboardController.java:45) +admin → user 리포지토리: Method calls method in (AdminDashboardController.java:56) +admin → user 리포지토리: Method calls method in (AdminDashboardController.java:78) admin → user 리포지토리: Method calls method in (AdminNotificationController.java:52) admin → user 리포지토리: Method calls method in (AdminUserController.java:172) admin → user 리포지토리: Method calls method in (AdminUserController.java:177) @@ -104,17 +104,17 @@ chatbot → facility 리포지토리: Constructor has type in (CareKnowledgeRetriever.java:0) chatbot → facility 리포지토리: Field has type in (ChatbotService.java:0) chatbot → facility 리포지토리: Method calls method in (CareKnowledgeRetriever.java:82) -chatbot → facility 리포지토리: Method calls method in (ChatbotService.java:520) +chatbot → facility 리포지토리: Method calls method in (ChatbotService.java:521) chatbot → policy 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository)> has parameter of type in (CareKnowledgeRetriever.java:0) chatbot → policy 리포지토리: Constructor (com.carecode.domain.chatbot.repository.ChatMessageRepository, com.carecode.domain.chatbot.repository.ChatSessionRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.chatbot.rag.CareKnowledgeRetriever, com.carecode.domain.chatbot.llm.ChatCompletionClient)> has parameter of type in (ChatbotService.java:0) chatbot → policy 리포지토리: Field has type in (CareKnowledgeRetriever.java:0) chatbot → policy 리포지토리: Field has type in (ChatbotService.java:0) chatbot → policy 리포지토리: Method calls method in (CareKnowledgeRetriever.java:68) -chatbot → policy 리포지토리: Method calls method in (ChatbotService.java:505) +chatbot → policy 리포지토리: Method calls method in (ChatbotService.java:506) chatbot → user 리포지토리: Constructor (com.carecode.domain.chatbot.repository.ChatMessageRepository, com.carecode.domain.chatbot.repository.ChatSessionRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.chatbot.rag.CareKnowledgeRetriever, com.carecode.domain.chatbot.llm.ChatCompletionClient)> has parameter of type in (ChatbotService.java:0) chatbot → user 리포지토리: Field has type in (ChatbotService.java:0) -chatbot → user 리포지토리: Method calls method in (ChatbotService.java:381) -chatbot → user 리포지토리: Method calls method in (ChatbotService.java:380) +chatbot → user 리포지토리: Method calls method in (ChatbotService.java:382) +chatbot → user 리포지토리: Method calls method in (ChatbotService.java:381) community → user 리포지토리: Constructor (com.carecode.domain.community.repository.PostRepository, com.carecode.domain.community.repository.TagRepository, com.carecode.domain.user.repository.UserRepository, org.springframework.security.crypto.password.PasswordEncoder)> has parameter of type in (CommunityInitializationService.java:0) community → user 리포지토리: Constructor (com.carecode.domain.community.repository.PostRepository, com.carecode.domain.community.repository.CommentRepository, com.carecode.domain.community.repository.TagRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.community.repository.PostLikeRepository, com.carecode.domain.community.repository.BookmarkRepository, com.carecode.domain.community.mapper.CommunityMapper, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (CommunityService.java:0) community → user 리포지토리: Constructor (com.carecode.domain.community.repository.ReportRepository, com.carecode.domain.community.repository.UserBlockRepository, com.carecode.domain.community.repository.PostRepository, com.carecode.domain.community.repository.CommentRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (ModerationService.java:0) @@ -134,36 +134,33 @@ facility → notification 리포지토리: Field calls method in (FacilityVacancyNotifier.java:130) facility → user 리포지토리: Constructor (com.carecode.domain.facility.repository.CareFacilityBookingRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.user.repository.UserRepository)> has parameter of type in (CareFacilityBookingService.java:0) facility → user 리포지토리: Constructor (com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.facility.repository.CareFacilityBookingRepository, com.carecode.core.ops.sync.SyncFreshnessService, com.carecode.domain.facility.repository.ReviewRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.facility.mapper.CareFacilityMapper, com.carecode.core.search.FullTextSearchSupport)> has parameter of type in (CareFacilityService.java:0) -facility → user 리포지토리: Constructor (com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.core.security.CurrentUserFacade, com.carecode.core.analytics.EventLogger)> has parameter of type in (FacilityWaitlistService.java:0) facility → user 리포지토리: Field has type in (CareFacilityBookingService.java:0) facility → user 리포지토리: Field has type in (CareFacilityService.java:0) -facility → user 리포지토리: Field has type in (FacilityWaitlistService.java:0) facility → user 리포지토리: Method calls method in (CareFacilityBookingService.java:52) facility → user 리포지토리: Method calls method in (CareFacilityBookingService.java:100) facility → user 리포지토리: Method calls method in (CareFacilityService.java:524) facility → user 리포지토리: Method calls method in (CareFacilityService.java:559) facility → user 리포지토리: Method calls method in (CareFacilityService.java:549) -facility → user 리포지토리: Method calls method in (FacilityWaitlistService.java:140) -health → facility 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +health → facility 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) health → facility 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → facility 리포지토리: Field has type in (HealthService.java:0) health → facility 리포지토리: Field has type in (SiblingOverviewService.java:0) -health → facility 리포지토리: Method calls method in (HealthService.java:524) +health → facility 리포지토리: Method calls method in (HealthService.java:556) health → facility 리포지토리: Method calls method in (SiblingOverviewService.java:91) -health → policy 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +health → policy 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) health → policy 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → policy 리포지토리: Constructor (com.carecode.domain.health.service.ChildService, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (ChildTimelineService.java:0) health → policy 리포지토리: Field has type in (HealthService.java:0) health → policy 리포지토리: Field has type in (SiblingOverviewService.java:0) health → policy 리포지토리: Field has type in (ChildTimelineService.java:0) -health → policy 리포지토리: Method calls method in (HealthService.java:520) +health → policy 리포지토리: Method calls method in (HealthService.java:552) health → policy 리포지토리: Method calls method in (SiblingOverviewService.java:101) health → policy 리포지토리: Method calls method in (ChildTimelineService.java:180) health → user 리포지토리: Constructor (com.carecode.domain.health.service.HealthService, com.carecode.domain.health.repository.HospitalRepository, com.carecode.core.ops.sync.SyncFreshnessService, com.carecode.domain.health.repository.HospitalLikeRepository, com.carecode.domain.health.repository.HospitalReviewRepository, com.carecode.domain.health.mapper.HospitalMapper, com.carecode.domain.health.mapper.HospitalReviewMapper, com.carecode.domain.user.repository.UserRepository)> has parameter of type in (HealthFacade.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.mapper.ChildMapper, com.carecode.core.security.CurrentUserFacade, com.carecode.domain.health.service.VaccinationScheduleService, com.carecode.core.analytics.EventLogger)> has parameter of type in (ChildService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (GrowthChartService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.ChildMapper, com.carecode.core.security.CurrentUserFacade, com.carecode.domain.health.service.VaccinationScheduleService, com.carecode.core.analytics.EventLogger)> has parameter of type in (ChildService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (GrowthChartService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.repository.ChildRepository)> has parameter of type in (VaccinationScheduleService.java:0) health → user 리포지토리: Field has type in (HealthFacade.java:0) @@ -173,25 +170,24 @@ health → user 리포지토리: Field has type in (HealthService.java:0) health → user 리포지토리: Field has type in (SiblingOverviewService.java:0) health → user 리포지토리: Field has type in (VaccinationScheduleService.java:0) -health → user 리포지토리: Method calls method in (HealthFacade.java:286) -health → user 리포지토리: Method calls method in (HealthFacade.java:183) -health → user 리포지토리: Method calls method in (ChildService.java:48) -health → user 리포지토리: Method calls method in (ChildService.java:84) -health → user 리포지토리: Method calls method in (ChildService.java:60) -health → user 리포지토리: Method calls method in (ChildService.java:98) -health → user 리포지토리: Method calls method in (ChildService.java:79) -health → user 리포지토리: Method calls method in (GrowthChartService.java:81) -health → user 리포지토리: Method calls method in (HealthService.java:303) -health → user 리포지토리: Method calls method in (HealthService.java:622) -health → user 리포지토리: Method calls method in (HealthService.java:95) -health → user 리포지토리: Method calls method in (HealthService.java:845) -health → user 리포지토리: Method calls method in (HealthService.java:837) -health → user 리포지토리: Method calls method in (HealthService.java:326) -health → user 리포지토리: Method calls method in (HealthService.java:258) -health → user 리포지토리: Method calls method in (HealthService.java:269) -health → user 리포지토리: Method calls method in (HealthService.java:280) -health → user 리포지토리: Method calls method in (HealthService.java:513) -health → user 리포지토리: Method calls method in (HealthService.java:291) +health → user 리포지토리: Method calls method in (HealthFacade.java:287) +health → user 리포지토리: Method calls method in (HealthFacade.java:184) +health → user 리포지토리: Method calls method in (ChildService.java:50) +health → user 리포지토리: Method calls method in (ChildService.java:86) +health → user 리포지토리: Method calls method in (ChildService.java:62) +health → user 리포지토리: Method calls method in (ChildService.java:106) +health → user 리포지토리: Method calls method in (ChildService.java:81) +health → user 리포지토리: Method calls method in (GrowthChartService.java:85) +health → user 리포지토리: Method calls method in (HealthService.java:311) +health → user 리포지토리: Method calls method in (HealthService.java:99) +health → user 리포지토리: Method calls method in (HealthService.java:879) +health → user 리포지토리: Method calls method in (HealthService.java:871) +health → user 리포지토리: Method calls method in (HealthService.java:334) +health → user 리포지토리: Method calls method in (HealthService.java:266) +health → user 리포지토리: Method calls method in (HealthService.java:277) +health → user 리포지토리: Method calls method in (HealthService.java:288) +health → user 리포지토리: Method calls method in (HealthService.java:545) +health → user 리포지토리: Method calls method in (HealthService.java:299) health → user 리포지토리: Method calls method in (SiblingOverviewService.java:44) health → user 리포지토리: Method calls method in (VaccinationScheduleService.java:32) notification → user 리포지토리: Constructor (com.carecode.domain.notification.service.NotificationService, com.carecode.domain.notification.service.NotificationPreferenceService, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.domain.notification.repository.NotificationPreferenceRepository)> has parameter of type in (NotificationFacade.java:0) @@ -203,66 +199,51 @@ notification → user 리포지토리: Field has type in (NotificationPreferenceService.java:0) notification → user 리포지토리: Field has type in (NotificationService.java:0) notification → user 리포지토리: Method calls method in (NotificationFacade.java:51) -notification → user 리포지토리: Method calls method in (NotificationFacade.java:209) -notification → user 리포지토리: Method calls method in (NotificationFacade.java:195) -notification → user 리포지토리: Method calls method in (NotificationFacade.java:188) -notification → user 리포지토리: Method calls method in (NotificationFacade.java:202) +notification → user 리포지토리: Method calls method in (NotificationFacade.java:200) +notification → user 리포지토리: Method calls method in (NotificationFacade.java:186) +notification → user 리포지토리: Method calls method in (NotificationFacade.java:179) +notification → user 리포지토리: Method calls method in (NotificationFacade.java:193) notification → user 리포지토리: Method calls method in (NotificationInitializationService.java:36) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:138) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:63) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:43) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:283) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:169) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:83) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:108) -notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:344) -notification → user 리포지토리: Method calls method in (NotificationService.java:97) -notification → user 리포지토리: Method calls method in (NotificationService.java:382) -notification → user 리포지토리: Method calls method in (NotificationService.java:56) -notification → user 리포지토리: Method calls method in (NotificationService.java:183) -notification → user 리포지토리: Method calls method in (NotificationService.java:172) -notification → user 리포지토리: Method calls method in (NotificationService.java:313) -notification → user 리포지토리: Method calls method in (NotificationService.java:325) -policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.BenefitAmountReportRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (BenefitReportSolicitor.java:0) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:139) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:64) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:44) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:284) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:170) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:84) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:109) +notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:345) +notification → user 리포지토리: Method calls method in (NotificationService.java:98) +notification → user 리포지토리: Method calls method in (NotificationService.java:316) +notification → user 리포지토리: Method calls method in (NotificationService.java:57) +notification → user 리포지토리: Method calls method in (NotificationService.java:184) +notification → user 리포지토리: Method calls method in (NotificationService.java:173) +notification → user 리포지토리: Method calls method in (NotificationService.java:247) +notification → user 리포지토리: Method calls method in (NotificationService.java:259) +policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.BenefitAmountReportRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (BenefitReportSolicitor.java:0) policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyChangeRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyChangeNotifier.java:0) -policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyDeadlineNotifier.java:0) +policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyDeadlineNotifier.java:0) policy → notification 리포지토리: Field has type in (BenefitReportSolicitor.java:0) policy → notification 리포지토리: Field has type in (PolicyChangeNotifier.java:0) policy → notification 리포지토리: Field has type in (PolicyDeadlineNotifier.java:0) -policy → notification 리포지토리: Method calls method in (BenefitReportSolicitor.java:145) +policy → notification 리포지토리: Method calls method in (BenefitReportSolicitor.java:146) policy → notification 리포지토리: Method calls method in (PolicyChangeNotifier.java:97) policy → notification 리포지토리: Method calls method in (PolicyDeadlineNotifier.java:140) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.BenefitAmountReportRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (BenefitReportSolicitor.java:0) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.BenefitAmountReportRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (BenefitReportSolicitor.java:0) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.core.security.CurrentUserFacade, com.carecode.core.analytics.EventLogger)> has parameter of type in (MissedBenefitService.java:0) +policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.BenefitAmountReportRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (BenefitReportSolicitor.java:0) policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyChangeRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyChangeNotifier.java:0) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyDeadlineNotifier.java:0) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyDeadlineNotifier.java:0) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.policy.mapper.PolicyMapper, com.carecode.core.security.CurrentUserFacade, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyRecommendationService.java:0) +policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyDeadlineNotifier.java:0) policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyBookmarkRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.mapper.PolicyMapper)> has parameter of type in (PolicyService.java:0) -policy → user 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.core.security.CurrentUserFacade, com.carecode.core.analytics.EventLogger, com.carecode.domain.policy.benefit.BenefitProjectionCalculator)> has parameter of type in (RegionalBenefitComparisonService.java:0) -policy → user 리포지토리: Field has type in (BenefitReportSolicitor.java:0) policy → user 리포지토리: Field has type in (BenefitReportSolicitor.java:0) -policy → user 리포지토리: Field has type in (MissedBenefitService.java:0) policy → user 리포지토리: Field has type in (PolicyChangeNotifier.java:0) -policy → user 리포지토리: Field has type in (PolicyDeadlineNotifier.java:0) policy → user 리포지토리: Field has type in (PolicyDeadlineNotifier.java:0) -policy → user 리포지토리: Field has type in (PolicyRecommendationService.java:0) policy → user 리포지토리: Field has type in (PolicyService.java:0) -policy → user 리포지토리: Field has type in (RegionalBenefitComparisonService.java:0) -policy → user 리포지토리: Method calls method in (BenefitReportSolicitor.java:108) -policy → user 리포지토리: Method calls method in (BenefitReportSolicitor.java:78) -policy → user 리포지토리: Method calls method in (MissedBenefitService.java:41) +policy → user 리포지토리: Method calls method in (BenefitReportSolicitor.java:77) policy → user 리포지토리: Method calls method in (PolicyChangeNotifier.java:117) -policy → user 리포지토리: Method calls method in (PolicyDeadlineNotifier.java:164) policy → user 리포지토리: Method calls method in (PolicyDeadlineNotifier.java:101) -policy → user 리포지토리: Method calls method in (PolicyRecommendationService.java:47) policy → user 리포지토리: Method calls method in (PolicyService.java:264) policy → user 리포지토리: Method calls method in (PolicyService.java:263) -policy → user 리포지토리: Method calls method in (RegionalBenefitComparisonService.java:50) user → community 리포지토리: Constructor (com.carecode.domain.user.repository.UserConsentRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.community.repository.PostRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (PrivacyService.java:0) user → community 리포지토리: Field has type in (PrivacyService.java:0) -user → community 리포지토리: Method calls method in (PrivacyService.java:124) +user → community 리포지토리: Method calls method in (PrivacyService.java:109) user → health 리포지토리: Constructor (com.carecode.domain.user.repository.UserConsentRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.community.repository.PostRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (PrivacyService.java:0) user → health 리포지토리: Field has type in (PrivacyService.java:0) -user → health 리포지토리: Method calls method in (PrivacyService.java:123) +user → health 리포지토리: Method calls method in (PrivacyService.java:108) diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index 71a4f56..a0c329f 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -31,7 +31,7 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. ## 규칙과 현재 위반 -진척: **489건(0단계) → 391건(1b 단계) → 383건(3a 단계)**. 남은 것은 도메인 경계(268)와 +진척: **489건(0단계) → 391건(1b) → 383건(3a) → 364건(4a)**. 남은 것은 도메인 경계(249)와 컨트롤러의 리포지토리 직접 사용(61), core 기반의 도메인 참조(50)입니다. > 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다. @@ -39,7 +39,7 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. | 규칙 | 위반 | 뜻 | |------|------|-----| -| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 268 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | +| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 249 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | | 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) | | 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 61 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | | core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 | @@ -163,6 +163,71 @@ OpenAPI 스펙은 237 → 231 경로가 됐습니다. - 프런트가 `z.record(z.unknown())` 으로 받던 내려받기 응답에 처음으로 모양이 생겼다 - 전체 테스트 **616개, 실패 0** +## 자녀 조회에 입구를 만들었다 (4a 단계) + +도메인 경계 위반 268건 중 자녀·사용자 리포지토리 직접 참조가 가장 큰 덩어리였습니다. 자녀는 +`user` 도메인에 속하는데, 쓰는 쪽은 health·policy·facility 입니다. 지금까지는 그 도메인들이 +`ChildRepository` 를 그냥 주입해 썼고, 그 결과 두 가지가 복제됐습니다. + +### 1. 소유권 검증이 세 벌이었고, 답이 셋으로 갈렸다 + +"이 아이가 내 아이인가" 를 세 군데가 각자 구현하고 있었습니다. 그런데 **같은 질문에 대한 답이 +경로마다 달랐습니다.** + +| 경로 | 남의 아이를 요청하면 | 문제 | +|------|---------------------|------| +| `/children/…` | 404 | 맞는 동작 | +| `HealthService.assertChildOwnedByUser` | 403 | **그 자녀가 존재한다는 사실을 알려준다.** ID 를 훑으면 "이 번호는 누군가의 자녀" 목록을 만들 수 있다 | +| `/health/vaccines/schedule`, `/health/checkups/schedule` | **500** | 위 403 이 `catch (Exception e)` 에 걸려 `CareServiceException` 으로 덮였다. 권한 거부가 서버 장애로 집계되고, 클라이언트는 자기가 잘못 보냈다는 걸 알 수 없다 | + +`ChildOwnershipContractTest` 가 여섯 경로를 전부 두드려 **404** 를 확인합니다. 테스트를 먼저 +쓰니 500 두 건이 바로 드러났습니다(`expected: 404 but was: 500`). + +함께 고친 것: `HealthService` 의 `catch (Exception e)` 10곳 중 8곳에 우리 예외를 그대로 +통과시키는 `catch (CareCodeException e) { throw e; }` 를 넣었습니다. 없으면 404·403 이 전부 +500 으로 덮입니다. (Slack 알림까지는 가지 않았습니다 — 알림은 최후 핸들러에서만 울리고 +`CareServiceException` 은 자기 핸들러가 있습니다. 테스트로 확인했습니다.) + +### 2. 월령 계산이 다섯 벌이었다 + +정책 추천·지원금 비교·마감 알림·제보 요청·대기 등록이 모두 +`ChronoUnit.MONTHS.between(child.getBirthDate(), today)` 를 각자 들고 있었고, **생일이 없을 때의 +처리가 곳마다 달랐습니다**(제외 / 0 / 통과). 월령은 추천 결과를 좌우하는 입력이라, 복제된 계산이 +하나만 어긋나면 같은 아이에게 다른 정책이 나옵니다. + +`ChildView.ageMonths(기준일)` 한 곳으로 모았습니다. 생일이 없으면 `null` 이고, **`null` 을 0 으로 +바꾸지 않습니다** — 0개월로 바꾸면 "갓 태어난 아이" 로 읽혀 신생아 정책이 추천됩니다. + +판단은 여전히 호출하는 쪽이 합니다. 마감 알림만 "생일을 모르면 배제하지 않는다" 로 다르게 +처리하는데, 놓친 손해가 성가심보다 크다는 판단이라 의도된 차이입니다. 계산은 하나, 판단은 각자. + +### 입구의 모양 + +```java +public interface ChildDirectory { + List childrenOf(Long userId); + ChildView requireOwnedChild(Long childId, Long parentUserId); // 없는 자녀와 남의 자녀를 구분하지 않는다 +} +``` + +엔티티가 아니라 `ChildView`(읽기 전용 값)를 돌려줍니다. 쓰는 쪽이 자녀 정보를 고치거나 +`child.getUser()` 로 사용자 정보까지 타고 들어가는 일이 없습니다. + +### 곁에서 드러난 것 + +- `FacilityWaitlist.child` 가 `@ManyToOne Child` 였는데 **읽는 곳이 없었습니다.** CHILD_ID 를 채우는 + 용도뿐이라 바로 위 `facilityId` 와 같은 방식(`Long childId`)으로 맞췄습니다. DB 외래키와 중복 등록 + 제약은 그대로입니다. +- 자녀 미등록·생일 없음·남의 대기 기록 수정이 `CareServiceException`(500)이었습니다. 각각 + 400·400·403 으로 고쳤습니다. 3단계에서 지운 7곳과 같은 뿌리입니다. + +### 숫자 + +- ArchUnit baseline 383 → **364** (도메인 경계 268 → 249) +- `ChildRepository` 를 쓰는 도메인: policy 5곳·facility 1곳 → **0**. health 5곳은 엔티티가 필요해 + 남아 있습니다(4b) +- 전체 테스트 **624개, 실패 0**. OpenAPI 스펙은 변화 없음 — 응답 모양을 바꾸지 않았습니다 + ## 다음 단계 | 단계 | 내용 | 효과 | @@ -172,5 +237,6 @@ OpenAPI 스펙은 237 → 231 경로가 됐습니다. | ~~2. 예외 한 체계~~ | ~~"찾을 수 없습니다" 34곳을 `ResourceNotFoundException` 으로~~ | **완료** — 500 → 404 | | ~~3a. 중복 입구 제거~~ | ~~같은 일을 하는 엔드포인트 7개 삭제~~ | **완료** — 391 → 383 | | ~~3b. `Map` 응답 → DTO~~ | ~~남은 17곳~~ | **완료** — 스펙 스키마 134 → 158 | -| 4. Child 를 제 자리로 | 엔티티는 `user`, 서비스는 `health` 에 쪼개져 있다 | 결합 1위 해소 | +| ~~4a. 자녀 조회 입구~~ | ~~policy·facility 가 `ChildRepository` 를 직접 쓰고 소유권 검증이 3중~~ | **완료** — 383 → 364, 404 로 통일 | +| 4b. Child 를 제 자리로 | `ChildService` 가 `health` 에 있다. health 는 아직 엔티티가 필요해 `ChildRepository` 를 쓴다 | 결합 추가 해소 | | 5. 거대 서비스 분해 | HealthService 977줄 | 2·3 과 함께 진행 | diff --git a/src/main/java/com/carecode/domain/facility/entity/FacilityWaitlist.java b/src/main/java/com/carecode/domain/facility/entity/FacilityWaitlist.java index 38149c8..2dbf750 100644 --- a/src/main/java/com/carecode/domain/facility/entity/FacilityWaitlist.java +++ b/src/main/java/com/carecode/domain/facility/entity/FacilityWaitlist.java @@ -1,6 +1,5 @@ package com.carecode.domain.facility.entity; -import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; @@ -36,9 +35,17 @@ public class FacilityWaitlist { @JoinColumn(name = "USER_ID", nullable = false) private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "CHILD_ID", nullable = false) - private Child child; + /** + * 자녀 ID. 연관({@code @ManyToOne Child})이 아니라 ID 로 둔다. + * + *

이 값을 읽는 곳이 없었다. 저장할 때 CHILD_ID 를 채우는 용도뿐인데 연관으로 두면 + * facility 도메인이 user 도메인의 엔티티를 알게 되고, 자녀 정보를 타고 사용자 정보까지 + * 들어갈 수 있다. 바로 위 {@code facilityId} 와 같은 방식으로 맞췄다. + * + *

DB 의 외래키(FK_WAITLIST_CHILD)와 중복 등록 제약(FACILITY_ID, CHILD_ID)은 그대로다. + */ + @Column(name = "CHILD_ID", nullable = false) + private Long childId; @Column(name = "WAIT_NUMBER") private Integer waitNumber; diff --git a/src/main/java/com/carecode/domain/facility/service/FacilityWaitlistService.java b/src/main/java/com/carecode/domain/facility/service/FacilityWaitlistService.java index 320fe6f..f8ca2d2 100644 --- a/src/main/java/com/carecode/domain/facility/service/FacilityWaitlistService.java +++ b/src/main/java/com/carecode/domain/facility/service/FacilityWaitlistService.java @@ -2,7 +2,8 @@ import com.carecode.core.analytics.EventLogger; import com.carecode.core.analytics.EventType; -import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.facility.dto.request.WaitlistRequest; @@ -11,9 +12,9 @@ import com.carecode.domain.facility.entity.FacilityWaitlist; import com.carecode.domain.facility.repository.CareFacilityRepository; import com.carecode.domain.facility.repository.FacilityWaitlistRepository; -import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -38,7 +39,7 @@ public class FacilityWaitlistService { private final FacilityWaitlistRepository waitlistRepository; private final CareFacilityRepository facilityRepository; - private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final CurrentUserFacade currentUserFacade; private final EventLogger eventLogger; @@ -47,10 +48,10 @@ public Long register(Long facilityId, WaitlistRequest request) { User user = currentUserFacade.requireCurrentUser(); CareFacility facility = facilityRepository.findById(facilityId) .orElseThrow(() -> new ResourceNotFoundException("시설을 찾을 수 없습니다: " + facilityId)); - Child child = resolveOwnChild(user, request.getChildId()); + ChildView child = resolveOwnChild(user, request.getChildId()); // 같은 아이·같은 시설의 중복 등록은 통계를 왜곡하므로 기존 기록을 그대로 돌려준다. - var existing = waitlistRepository.findByFacilityIdAndChildId(facilityId, child.getId()); + var existing = waitlistRepository.findByFacilityIdAndChildId(facilityId, child.childId()); if (existing.isPresent()) { return existing.get().getId(); } @@ -58,7 +59,7 @@ public Long register(Long facilityId, WaitlistRequest request) { FacilityWaitlist saved = waitlistRepository.save(FacilityWaitlist.builder() .facilityId(facility.getId()) .user(user) - .child(child) + .childId(child.childId()) .waitNumber(request.getWaitNumber()) .appliedAt(request.getAppliedAt() != null ? request.getAppliedAt() : LocalDate.now()) .status(FacilityWaitlist.WaitStatus.WAITING) @@ -77,7 +78,9 @@ public void resolve(Long waitlistId, String status, LocalDate resolvedAt, String .orElseThrow(() -> new ResourceNotFoundException("대기 기록을 찾을 수 없습니다: " + waitlistId)); if (!entry.getUser().getId().equals(user.getId())) { - throw new CareServiceException("본인의 대기 기록만 수정할 수 있습니다."); + // 403 이어야 한다. CareServiceException 은 기본 매핑이 500 이라, 남의 기록을 + // 수정하려는 요청에 서버 오류가 나가고 운영 알림까지 울렸다. + throw new BusinessException(ErrorCode.FORBIDDEN, "본인의 대기 기록만 수정할 수 있습니다."); } entry.resolve(FacilityWaitlist.WaitStatus.valueOf(status), resolvedAt, note); } @@ -136,23 +139,24 @@ private List buildReasons(int samples, int median, long waiting) { return reasons; } - /** 남의 아이로 등록하지 못하게 소유권을 확인한다. */ - private Child resolveOwnChild(User user, Long childId) { - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); - if (children.isEmpty()) { - throw new CareServiceException("등록된 자녀가 없습니다."); + /** + * 남의 아이로 등록하지 못하게 소유권을 확인한다. 지정하지 않으면 최근에 등록한 자녀다. + * + *

두 경우 모두 사용자가 고칠 수 있는 상태라 400 이어야 한다. 전에는 + * {@code CareServiceException} 이어서 500 이 나가고 운영 알림까지 울렸다. + */ + private ChildView resolveOwnChild(User user, Long childId) { + if (childId != null) { + return childDirectory.requireOwnedChild(childId, user.getId()); } - if (childId == null) { - return children.get(0); + List children = childDirectory.childrenOf(user.getId()); + if (children.isEmpty()) { + throw new BusinessException("등록된 자녀가 없습니다."); } - return children.stream() - .filter(c -> c.getId().equals(childId)) - .findFirst() - .orElseThrow(() -> new CareServiceException("본인의 자녀만 등록할 수 있습니다.")); + return children.get(0); } - private Integer monthsOld(Child child) { - return child.getBirthDate() == null ? null - : (int) ChronoUnit.MONTHS.between(child.getBirthDate(), LocalDate.now()); + private Integer monthsOld(ChildView child) { + return child.ageMonths(LocalDate.now()); } } diff --git a/src/main/java/com/carecode/domain/health/service/ChildService.java b/src/main/java/com/carecode/domain/health/service/ChildService.java index f8a94c2..69a6ff2 100644 --- a/src/main/java/com/carecode/domain/health/service/ChildService.java +++ b/src/main/java/com/carecode/domain/health/service/ChildService.java @@ -7,6 +7,7 @@ import com.carecode.domain.health.dto.request.ChildCreateRequest; import com.carecode.domain.health.dto.response.ChildInfoResponse; import com.carecode.domain.health.mapper.ChildMapper; +import com.carecode.domain.user.app.ChildDirectory; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.ChildRepository; @@ -27,6 +28,7 @@ public class ChildService { private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final ChildMapper childMapper; private final CurrentUserFacade currentUserFacade; private final VaccinationScheduleService vaccinationScheduleService; @@ -92,17 +94,17 @@ public Child requireOwned(Long childId) { return requireOwnedChild(childId); } - /** 아이 조회 + 소유권 검증. 남의 아이 정보에 접근하지 못하도록 보호자 본인 것만 반환한다. */ + /** + * 아이 조회 + 소유권 검증. 판단은 {@link ChildDirectory} 한 곳에서만 한다. + * + *

여기서 엔티티가 필요한 이유: 자녀 수정·삭제가 이 서비스에 있다. 검증 자체는 입구에 + * 맡기고, 통과한 뒤 같은 트랜잭션의 1차 캐시에서 엔티티를 꺼낸다(추가 쿼리가 나가지 않는다). + */ private Child requireOwnedChild(Long childId) { User parent = currentUserFacade.requireCurrentUser(); - Child child = childRepository.findById(childId) + childDirectory.requireOwnedChild(childId, parent.getId()); + return childRepository.findById(childId) .orElseThrow(() -> new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId)); - - if (child.getUser() == null || !child.getUser().getId().equals(parent.getId())) { - // 존재 여부 자체를 숨기기 위해 404 로 응답한다. - throw new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId); - } - return child; } private Integer calculateAge(LocalDate birthDate) { diff --git a/src/main/java/com/carecode/domain/health/service/GrowthChartService.java b/src/main/java/com/carecode/domain/health/service/GrowthChartService.java index 0b8ed14..4d5c259 100644 --- a/src/main/java/com/carecode/domain/health/service/GrowthChartService.java +++ b/src/main/java/com/carecode/domain/health/service/GrowthChartService.java @@ -9,6 +9,7 @@ import com.carecode.domain.health.growth.GrowthPercentileResult; import com.carecode.domain.health.growth.Sex; import com.carecode.domain.health.repository.HealthRecordRepository; +import com.carecode.domain.user.app.ChildDirectory; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.ChildRepository; @@ -33,6 +34,7 @@ public class GrowthChartService { private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final HealthRecordRepository healthRecordRepository; private final CurrentUserFacade currentUserFacade; @@ -76,15 +78,12 @@ public Optional getLatestPercentile(Long childId, GrowthMet return points.isEmpty() ? Optional.empty() : Optional.of(points.get(points.size() - 1)); } + /** 소유권 판단은 {@link ChildDirectory} 한 곳에서만 한다. 여기 있던 복제본을 지웠다. */ private Child requireOwnedChild(Long childId) { User parent = currentUserFacade.requireCurrentUser(); - Child child = childRepository.findById(childId) + childDirectory.requireOwnedChild(childId, parent.getId()); + return childRepository.findById(childId) .orElseThrow(() -> new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId)); - - if (child.getUser() == null || !child.getUser().getId().equals(parent.getId())) { - throw new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId); - } - return child; } /** 오늘 기준 개월 수. */ diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 668603b..2c69898 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -31,6 +31,7 @@ import com.carecode.domain.health.repository.VaccinationScheduleRepository; import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.facility.repository.CareFacilityRepository; +import com.carecode.domain.user.app.ChildDirectory; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.ChildRepository; @@ -74,6 +75,7 @@ public class HealthService { private final ConsentGuard consentGuard; private final HealthRecordAttachmentRepository healthRecordAttachmentRepository; private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final UserRepository userRepository; private final PolicyRepository policyRepository; private final CareFacilityRepository careFacilityRepository; @@ -150,6 +152,10 @@ public List getHealthRecordsByUserId(String userId, Long a return records.stream() .map(healthRecordMapper::toResponse) .collect(Collectors.toList()); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("사용자별 건강 기록 조회 실패: {}", e.getMessage()); throw new CareServiceException("사용자별 건강 기록 조회 중 오류가 발생했습니다.", e); @@ -360,6 +366,10 @@ public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) .recordTypeDistribution(calculateRecordTypeDistribution(records)) .upcomingEvents(generateUpcomingEvents(records)) .build(); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("건강 통계 조회 실패: {}", e.getMessage()); throw new CareServiceException("건강 통계 조회 중 오류가 발생했습니다.", e); @@ -382,6 +392,10 @@ public List getVaccineSchedule(String childId, Long act return vaccineRecords.stream() .map(this::convertToVaccineScheduleResponse) .collect(Collectors.toList()); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("예방접종 스케줄 조회 실패: {}", e.getMessage()); throw new CareServiceException("예방접종 스케줄 조회 중 오류가 발생했습니다.", e); @@ -402,6 +416,10 @@ public List getCheckupSchedule(String childId, Long act return checkupRecords.stream() .map(this::convertToCheckupScheduleResponse) .collect(Collectors.toList()); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("건강 검진 스케줄 조회 실패: {}", e.getMessage()); throw new CareServiceException("건강 검진 스케줄 조회 중 오류가 발생했습니다.", e); @@ -425,6 +443,10 @@ public List getHealthRecordsByDateRangeAsc(Long childId, L return records.stream() .map(healthRecordMapper::toResponse) .collect(Collectors.toList()); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("기간별 건강 기록 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("기간별 건강 기록 조회 중 오류가 발생했습니다.", e); @@ -445,6 +467,10 @@ public List getHealthRecordsByType(Long childId, HealthRec return records.stream() .map(healthRecordMapper::toResponse) .collect(Collectors.toList()); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("타입별 건강 기록 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("타입별 건강 기록 조회 중 오류가 발생했습니다.", e); @@ -502,6 +528,10 @@ public List getHealthAlerts(String userId, Long actorUserId .filter(r -> r.getNextDate() != null && r.getNextDate().isAfter(java.time.LocalDate.now())) .map(this::convertToHealthAlertResponse) .collect(Collectors.toList()); + } catch (CareCodeException e) { + // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 + // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. + throw e; } catch (Exception e) { log.error("건강 알림 조회 실패: {}", e.getMessage()); throw new CareServiceException("건강 알림 조회 중 오류가 발생했습니다.", e); @@ -609,20 +639,23 @@ private void assertHealthRecordOwnedByUser(HealthRecord record, Long actorUserId } } + /** + * 자녀 소유권 확인. 판단은 {@link ChildDirectory} 한 곳에서만 한다. + * + *

전에는 여기서 직접 비교하고 403 을 던졌다. 같은 질문에 {@code /children/…} 은 404 를 + * 주고 있었는데, 403 은 그 자녀가 존재한다는 사실을 알려준다 — ID 를 훑으면 "이 번호는 + * 누군가의 자녀" 목록을 만들 수 있다. 게다가 그 403 이 아래 {@code catch (Exception e)} 에 + * 걸려 500 으로 덮이는 경로도 있었다(접종·검진 일정 조회). + * + *

이미 읽어 둔 엔티티를 넘겨도 같은 트랜잭션 안에서는 1차 캐시에 올라와 있어 추가 쿼리가 + * 나가지 않는다. + */ private void assertChildOwnedByUser(Child child, Long actorUserId) { - if (actorUserId == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "인증이 필요합니다."); - } - User parent = child.getUser(); - if (parent == null || parent.getId() == null || !parent.getId().equals(actorUserId)) { - throw new BusinessException(ErrorCode.FORBIDDEN, "해당 자녀 정보에 접근할 권한이 없습니다."); - } + assertChildOwnedByUserId(child.getId(), actorUserId); } private void assertChildOwnedByUserId(Long childId, Long actorUserId) { - Child child = childRepository.findById(childId) - .orElseThrow(() -> new ChildNotFoundException(childId)); - assertChildOwnedByUser(child, actorUserId); + childDirectory.requireOwnedChild(childId, actorUserId); } private void assertUserIdBelongsToActor(String userId, Long actorUserId) { diff --git a/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java b/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java index f9f33c3..0b60c7e 100644 --- a/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java +++ b/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java @@ -89,7 +89,7 @@ private VaccinationSchedule findNextVaccination(Child child) { private long countWaitlists(Child child) { try { return waitlistRepository.findByUserIdOrderByAppliedAtDesc(child.getUser().getId()).stream() - .filter(w -> w.getChild() != null && w.getChild().getId().equals(child.getId())) + .filter(w -> child.getId().equals(w.getChildId())) .count(); } catch (Exception e) { return 0; diff --git a/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java b/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java index 7412eec..083f81a 100644 --- a/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java +++ b/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java @@ -8,9 +8,9 @@ import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.BenefitAmountReportRepository; import com.carecode.domain.policy.repository.PolicyRepository; -import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; import com.carecode.domain.user.repository.UserRepository; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -21,7 +21,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -45,7 +44,7 @@ public class BenefitReportSolicitor { private final PolicyRepository policyRepository; private final BenefitAmountReportRepository reportRepository; private final UserRepository userRepository; - private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final NotificationRepository notificationRepository; private final NotificationDispatcher dispatcher; private final EventLogger eventLogger; @@ -105,7 +104,7 @@ public SolicitResult solicitReports() { /** 아이가 대상 연령을 최근에 지난 정책. 방금 받아봤을 가능성이 높은 구간이다. */ private List findRecentlyPassed(User user, List policies) { - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + List children = childDirectory.childrenOf(user.getId()); if (children.isEmpty()) { return List.of(); } @@ -118,10 +117,12 @@ private List findRecentlyPassed(User user, List policies) { continue; } boolean recentlyPassed = children.stream().anyMatch(child -> { - if (child.getBirthDate() == null) { + Integer months = child.ageMonths(today); + // 월령을 모르면 "최근에 지났다" 를 판단할 수 없다. 모르는 채로 제보를 요청하면 + // 받아본 적 없는 지원금의 금액을 묻게 된다. + if (months == null) { return false; } - long months = ChronoUnit.MONTHS.between(child.getBirthDate(), today); long sincePassed = months - policy.getTargetAgeMax(); return sincePassed > 0 && sincePassed <= askWithinMonths; }); diff --git a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java index 534ae6a..22e2a10 100644 --- a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java +++ b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java @@ -7,9 +7,9 @@ import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.PolicyRepository; -import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -32,13 +31,13 @@ public class MissedBenefitService { private static final int CANDIDATE_SIZE = 300; private final PolicyRepository policyRepository; - private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final CurrentUserFacade currentUserFacade; private final EventLogger eventLogger; public MissedBenefitSummaryResponse findMissedBenefits() { User user = currentUserFacade.requireCurrentUser(); - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + List children = childDirectory.childrenOf(user.getId()); LocalDate today = LocalDate.now(); List claimable = new ArrayList<>(); @@ -53,11 +52,12 @@ public MissedBenefitSummaryResponse findMissedBenefits() { .findByIsActiveTrueOrderByPriorityDescViewCountDesc(PageRequest.of(0, CANDIDATE_SIZE)) .getContent(); - for (Child child : children) { - if (child.getBirthDate() == null) { + for (ChildView child : children) { + Integer currentMonths = child.ageMonths(today); + // 생일을 모르면 월령을 알 수 없고, 월령 없이는 "놓친 시기" 를 판단할 수 없다. + if (currentMonths == null) { continue; } - int currentMonths = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), today); for (Policy policy : candidates) { if (!hasPassedAgeWindow(policy, currentMonths)) { @@ -124,11 +124,11 @@ private Eligibility checkEligibility(Policy policy, User user, int childCount) { return income <= threshold ? Eligibility.ELIGIBLE : Eligibility.NOT_ELIGIBLE; } - private MissedBenefitResponse toResponse(Policy policy, Child child, int currentMonths, + private MissedBenefitResponse toResponse(Policy policy, ChildView child, int currentMonths, Eligibility eligibility, LocalDate today) { List reasons = new ArrayList<>(); reasons.add(String.format("%s 님이 %d~%d개월이던 시기에 대상이었습니다.", - child.getName(), + child.name(), policy.getTargetAgeMin() != null ? policy.getTargetAgeMin() : 0, policy.getTargetAgeMax())); @@ -161,7 +161,7 @@ private MissedBenefitResponse toResponse(Policy policy, Child child, int current return MissedBenefitResponse.builder() .policyId(policy.getId()) .title(policy.getTitle()) - .childName(child.getName()) + .childName(child.name()) .eligibleFromMonth(policy.getTargetAgeMin()) .eligibleToMonth(policy.getTargetAgeMax()) .claimable(claimable) diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java b/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java index 2a54fef..9a6e607 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java @@ -9,9 +9,9 @@ import com.carecode.domain.policy.entity.PolicyDeadlineNotice; import com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository; import com.carecode.domain.policy.repository.PolicyRepository; -import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; import com.carecode.domain.user.repository.UserRepository; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -59,7 +59,7 @@ public class PolicyDeadlineNotifier { private final PolicyRepository policyRepository; private final PolicyDeadlineNoticeRepository noticeRepository; private final UserRepository userRepository; - private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final NotificationRepository notificationRepository; private final NotificationDispatcher dispatcher; private final EventLogger eventLogger; @@ -161,7 +161,7 @@ private int notify(Policy policy, List activeUsers, LocalDate today) { * 다만 자녀가 없거나 지역·연령이 명확히 어긋나면 보내지 않는다. */ private boolean isTarget(Policy policy, User user, LocalDate today) { - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + List children = childDirectory.childrenOf(user.getId()); if (children.isEmpty()) { return false; } @@ -198,12 +198,13 @@ private boolean matchesRegion(Policy policy, User user) { return address.contains(region) || region.contains(address); } - private boolean matchesAge(Policy policy, Child child, LocalDate today) { - if (child.getBirthDate() == null) { - // 생일을 모르면 연령으로 배제하지 않는다. + private boolean matchesAge(Policy policy, ChildView child, LocalDate today) { + Integer months = child.ageMonths(today); + if (months == null) { + // 생일을 모르면 연령으로 배제하지 않는다. 다른 화면(추천·놓친 지원금)은 반대로 + // 제외하는데, 마감 알림은 놓친 손해가 성가심보다 크다는 판단이 달라서다. return true; } - int months = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), today); Integer min = policy.getTargetAgeMin(); if (min != null && months < min) { diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java index 8fbda79..172d675 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -7,9 +7,9 @@ import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.mapper.PolicyMapper; import com.carecode.domain.policy.repository.PolicyRepository; -import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -36,7 +36,7 @@ public class PolicyRecommendationService { private static final int DEADLINE_SOON_DAYS = 30; private final PolicyRepository policyRepository; - private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final PolicyMapper policyMapper; private final CurrentUserFacade currentUserFacade; private final EventLogger eventLogger; @@ -44,7 +44,7 @@ public class PolicyRecommendationService { /** 로그인 사용자에게 맞는 정책을 점수 순으로 반환한다. */ public List recommendForCurrentUser(int limit) { User user = currentUserFacade.requireCurrentUser(); - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + List children = childDirectory.childrenOf(user.getId()); LocalDate today = LocalDate.now(); List candidates = policyRepository @@ -75,7 +75,7 @@ public List recommendForCurrentUser(int limit) { } /** 연령·소득·자녀수 조건에 맞지 않으면 0점으로 제외한다. */ - private int score(Policy policy, User user, List children, LocalDate today, List reasons) { + private int score(Policy policy, User user, List children, LocalDate today, List reasons) { if (!meetsHouseholdConditions(policy, user, children.size(), reasons)) { return 0; } @@ -84,12 +84,12 @@ private int score(Policy policy, User user, List children, LocalDate toda boolean hasAgeCondition = policy.getTargetAgeMin() != null || policy.getTargetAgeMax() != null; if (hasAgeCondition) { - Child matched = children.stream().filter(c -> matchesAge(policy, c, today)).findFirst().orElse(null); + ChildView matched = children.stream().filter(c -> matchesAge(policy, c, today)).findFirst().orElse(null); if (matched == null) { return 0; } score += SCORE_AGE_MATCH; - reasons.add(matched.getName() + " 연령 조건에 해당합니다."); + reasons.add(matched.name() + " 연령 조건에 해당합니다."); } if (matchesRegion(policy, user, reasons)) { @@ -135,11 +135,12 @@ private boolean meetsHouseholdConditions(Policy policy, User user, int childCoun } /** 정책 대상 월령과 아이의 월령을 비교한다. 시드 데이터 기준 targetAge 단위는 개월이다. */ - private boolean matchesAge(Policy policy, Child child, LocalDate today) { - if (child.getBirthDate() == null) { + private boolean matchesAge(Policy policy, ChildView child, LocalDate today) { + Integer months = child.ageMonths(today); + // 월령을 모르면 조건에 맞는다고 볼 수 없다. 모르는 것을 "해당" 으로 치면 추천 이유가 거짓이 된다. + if (months == null) { return false; } - long months = ChronoUnit.MONTHS.between(child.getBirthDate(), today); Integer min = policy.getTargetAgeMin(); Integer max = policy.getTargetAgeMax(); return (min == null || months >= min) && (max == null || months <= max); diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java index 4ce69d2..f5fd9fc 100644 --- a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -2,7 +2,7 @@ import com.carecode.domain.policy.benefit.BenefitPaymentType; import com.carecode.domain.policy.benefit.BenefitProjectionCalculator; -import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.BusinessException; import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.analytics.EventLogger; import com.carecode.core.analytics.EventType; @@ -11,16 +11,15 @@ import com.carecode.domain.policy.dto.response.RegionalBenefitResponse; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.PolicyRepository; -import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -41,18 +40,18 @@ public class RegionalBenefitComparisonService { private static final int TOP_CONTRIBUTORS = 3; private final PolicyRepository policyRepository; - private final ChildRepository childRepository; + private final ChildDirectory childDirectory; private final CurrentUserFacade currentUserFacade; private final EventLogger eventLogger; private final BenefitProjectionCalculator calculator; public RegionalBenefitComparisonResponse compare(Long childId, Integer years, Integer limit) { User user = currentUserFacade.requireCurrentUser(); - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); - Child child = resolveChild(children, childId); + List children = childDirectory.childrenOf(user.getId()); + ChildView child = resolveChild(children, childId); int horizon = resolveHorizon(years); - int currentAgeMonths = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), LocalDate.now()); + int currentAgeMonths = child.ageMonths(LocalDate.now()); // 자격이 안 되는 정책을 총액에 넣으면 "이사하면 얼마 더" 가 통째로 틀어진다. List activePolicies = policyRepository.findByIsActiveTrue().stream() @@ -89,7 +88,7 @@ public RegionalBenefitComparisonResponse compare(Long childId, Integer years, In eventLogger.log(EventType.REGIONAL_COMPARISON_VIEWED, user.getId(), baseRegion); return RegionalBenefitComparisonResponse.builder() - .childName(child.getName()) + .childName(child.name()) .childAgeMonths(currentAgeMonths) .horizonMonths(horizon) .baseRegion(baseRegion) @@ -223,16 +222,22 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo return new RegionSummary(total, cash, nonCash, verified, unknownAmount, contributions); } - private Child resolveChild(List children, Long childId) { + /** + * 비교 기준이 될 자녀를 고른다. 지정하지 않으면 가장 최근에 등록한 자녀다. + * + *

세 경우 모두 사용자가 고칠 수 있는 상태이므로 400 이어야 한다. 전에는 + * {@code CareServiceException} 이라 500 이 나가고 운영 알림까지 울렸다. + */ + private ChildView resolveChild(List children, Long childId) { if (children.isEmpty()) { - throw new CareServiceException("등록된 자녀가 없습니다. 자녀를 먼저 등록해 주세요."); + throw new BusinessException("등록된 자녀가 없습니다. 자녀를 먼저 등록해 주세요."); } - Child child = childId == null ? children.get(0) - : children.stream().filter(c -> c.getId().equals(childId)).findFirst() + ChildView child = childId == null ? children.get(0) + : children.stream().filter(c -> c.childId().equals(childId)).findFirst() .orElseThrow(() -> new ResourceNotFoundException("자녀를 찾을 수 없습니다: " + childId)); - if (child.getBirthDate() == null) { - throw new CareServiceException("자녀의 생년월일이 없어 지원금을 계산할 수 없습니다."); + if (child.birthDate() == null) { + throw new BusinessException("자녀의 생년월일이 없어 지원금을 계산할 수 없습니다."); } return child; } diff --git a/src/main/java/com/carecode/domain/user/app/ChildDirectory.java b/src/main/java/com/carecode/domain/user/app/ChildDirectory.java new file mode 100644 index 0000000..8163109 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/app/ChildDirectory.java @@ -0,0 +1,45 @@ +package com.carecode.domain.user.app; + +import java.util.List; + +/** + * 자녀 정보를 다른 도메인에 내주는 입구. + * + *

자녀는 {@code user} 도메인에 속한다(계정에 딸린 정보이고, 계정이 사라지면 함께 익명화된다). + * 그런데 자녀를 쓰는 쪽은 health·policy·facility 다. 지금까지는 그 도메인들이 + * {@code ChildRepository} 를 그냥 주입해 썼다. 그렇게 하면 두 가지가 생긴다. + * + *

    + *
  • 소유권 검증이 흩어진다. "이 아이가 이 사용자의 아이인가" 를 세 군데가 각자 + * 구현하고 있었다. 보안 검증은 복제되면 한 쪽이 조용히 뒤처진다.
  • + *
  • 같은 계산이 복제된다. 월령 계산이 다섯 군데에 있었고 생일이 없을 때의 처리가 + * 곳마다 달랐다. {@link ChildView#ageMonths} 참고.
  • + *
+ * + *

그래서 읽기는 이 입구로만 받는다. 엔티티가 아니라 {@link ChildView} 를 돌려주므로, + * 쓰는 쪽이 실수로 자녀 정보를 고치거나 연관을 타고 사용자 정보까지 들어가는 일이 없다. + * + *

여기 없는 것: 자녀 등록·수정·삭제. 그건 {@code user} 도메인 안에서만 한다. + * 또 자녀 엔티티 자체가 필요한 경우(건강 기록을 자녀에 붙이는 등)는 {@code health} 가 + * 같은 도메인 안에서 다루므로 이 입구를 쓰지 않는다. + */ +public interface ChildDirectory { + + /** + * 한 사용자의 자녀 목록. 최근에 등록한 순이다. + * + *

등록된 자녀가 없으면 빈 목록이다. 호출하는 쪽은 그 경우를 "0개월 아이" 로 바꾸지 + * 말고 추천을 내지 않는 쪽으로 처리해야 한다. + */ + List childrenOf(Long userId); + + /** + * 그 사용자의 자녀인지 확인하고 돌려준다. + * + *

없는 자녀와 남의 자녀를 구분하지 않고 둘 다 "찾을 수 없다" 로 끝낸다. + * 403 과 404 를 나눠 주면 ID 를 훑어 "이 번호는 누군가의 자녀" 라는 사실을 알아낼 수 있다. + * + * @throws com.carecode.core.exception.ChildNotFoundException 없거나 다른 사용자의 자녀일 때 + */ + ChildView requireOwnedChild(Long childId, Long parentUserId); +} diff --git a/src/main/java/com/carecode/domain/user/app/ChildView.java b/src/main/java/com/carecode/domain/user/app/ChildView.java new file mode 100644 index 0000000..0359d00 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/app/ChildView.java @@ -0,0 +1,62 @@ +package com.carecode.domain.user.app; + +import com.carecode.domain.user.entity.Child; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +/** + * 다른 도메인이 보는 자녀. 엔티티가 아니라 읽기 전용 값이다. + * + *

정책 추천·지원금 비교·대기 등록은 모두 자녀의 "월령" 으로 판단한다. 그런데 그 계산이 + * {@code ChronoUnit.MONTHS.between(child.getBirthDate(), today)} 로 다섯 군데에 각자 복제돼 + * 있었고, 생일이 없을 때의 처리도 곳마다 달랐다(어떤 곳은 제외, 어떤 곳은 0). 월령은 추천 + * 결과를 좌우하는 입력이라, 복제된 계산이 하나만 어긋나면 같은 아이에게 다른 정책이 나온다. + * + *

그래서 월령 계산을 여기 한 곳에 둔다. 생일이 없으면 {@code null} 이고, {@code null} 을 + * 숫자로 바꿔 주지 않는다 — 0개월로 바꾸면 "갓 태어난 아이" 로 읽혀 신생아 정책이 추천된다. + * + * @param childId 자녀 ID + * @param name 이름. 추천 이유 문장에 들어간다("첫째 아이는 ...") + * @param birthDate 생년월일. 없을 수 있다 + * @param gender 성별 문자열. 시드·공공데이터가 섞여 들어와 코드값으로 고정하지 않는다 + * @param specialNeeds 특별히 고려할 사항. 비어 있으면 {@code null} + */ +public record ChildView( + Long childId, + String name, + LocalDate birthDate, + String gender, + String specialNeeds) { + + public static ChildView from(Child child) { + return new ChildView( + child.getId(), + child.getName(), + child.getBirthDate(), + child.getGender(), + blankToNull(child.getSpecialNeeds())); + } + + /** + * 기준일 시점의 월령. 생일이 없으면 {@code null}. + * + *

기준일을 받는 쪽이 본체다. {@code LocalDate.now()} 를 안에서 부르면 테스트가 "오늘" + * 에 따라 흔들린다. + */ + public Integer ageMonths(LocalDate on) { + if (birthDate == null || on == null) { + return null; + } + return (int) ChronoUnit.MONTHS.between(birthDate, on); + } + + /** 특별 고려사항이 있는지. 빈 문자열과 {@code null} 을 같게 본다. */ + public boolean hasSpecialNeeds() { + return specialNeeds != null; + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/src/main/java/com/carecode/domain/user/service/ChildDirectoryService.java b/src/main/java/com/carecode/domain/user/service/ChildDirectoryService.java new file mode 100644 index 0000000..eb15707 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/service/ChildDirectoryService.java @@ -0,0 +1,59 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.exception.ChildNotFoundException; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * {@link ChildDirectory} 구현. 자녀 조회가 도메인 밖으로 나가는 유일한 길이다. + * + *

소유권 검증은 여기 한 곳에만 있다. 이전에는 같은 검증이 {@code ChildService}, + * {@code GrowthChartService}, {@code HealthService} 세 군데에 각자 쓰여 있었다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChildDirectoryService implements ChildDirectory { + + private final ChildRepository childRepository; + + @Override + public List childrenOf(Long userId) { + if (userId == null) { + return List.of(); + } + return childRepository.findByUserIdOrderByCreatedAtDesc(userId).stream() + .map(ChildView::from) + .toList(); + } + + @Override + public ChildView requireOwnedChild(Long childId, Long parentUserId) { + return ChildView.from(requireOwnedEntity(childId, parentUserId)); + } + + /** + * 같은 도메인 안에서 엔티티가 필요할 때 쓴다(자녀 수정·삭제). + * + *

{@code public} 이 아닌 이유: 엔티티를 도메인 밖으로 내보내면 호출하는 쪽이 + * {@code child.getUser()} 로 사용자 정보까지 타고 들어갈 수 있다. + */ + Child requireOwnedEntity(Long childId, Long parentUserId) { + Child child = childRepository.findById(childId) + .orElseThrow(() -> new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId)); + + // 남의 자녀도 "없다" 로 끝낸다. 403 과 404 를 나누면 ID 를 훑어 존재 여부를 알아낼 수 있다. + if (parentUserId == null || child.getUser() == null + || !parentUserId.equals(child.getUser().getId())) { + throw new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId); + } + return child; + } +} diff --git a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java index 30b3690..04dbef9 100644 --- a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java +++ b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java @@ -9,6 +9,7 @@ import com.carecode.domain.health.repository.HealthRecordRepository; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.app.ChildDirectory; import com.carecode.domain.user.repository.ChildRepository; import com.carecode.domain.user.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; @@ -39,6 +40,10 @@ class HealthServiceTest { @Mock private ChildRepository childRepository; + /** 소유권 판단은 이 입구가 한다. 목이 없으면 null 이 되어 NPE 가 CareServiceException 으로 덮인다. */ + @Mock + private ChildDirectory childDirectory; + @Mock private UserRepository userRepository; diff --git a/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java b/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java index d8aa772..87f0fa2 100644 --- a/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java @@ -6,7 +6,8 @@ import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.core.analytics.EventLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -27,7 +28,7 @@ class MissedBenefitServiceTest { private PolicyRepository policyRepository; - private ChildRepository childRepository; + private ChildDirectory childDirectory; private CurrentUserFacade currentUserFacade; private MissedBenefitService service; @@ -36,13 +37,13 @@ class MissedBenefitServiceTest { @BeforeEach void setUp() { policyRepository = mock(PolicyRepository.class); - childRepository = mock(ChildRepository.class); + childDirectory = mock(ChildDirectory.class); currentUserFacade = mock(CurrentUserFacade.class); user = User.builder().id(1L).name("부모").build(); when(currentUserFacade.requireCurrentUser()).thenReturn(user); - service = new MissedBenefitService(policyRepository, childRepository, currentUserFacade, + service = new MissedBenefitService(policyRepository, childDirectory, currentUserFacade, mock(EventLogger.class)); } @@ -174,7 +175,7 @@ void sortsByAmountDescending() { @Test @DisplayName("아이가 없으면 조회하지 않는다") void returnsEmptyWithoutChildren() { - when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of()); + when(childDirectory.childrenOf(anyLong())).thenReturn(List.of()); MissedBenefitSummaryResponse result = service.findMissedBenefits(); @@ -183,11 +184,8 @@ void returnsEmptyWithoutChildren() { } private void givenChildAgedMonths(int months) { - Child child = Child.builder() - .name("아이") - .birthDate(LocalDate.now().minusMonths(months)) - .build(); - when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(child)); + ChildView child = new ChildView(1L, "아이", LocalDate.now().minusMonths(months), "FEMALE", null); + when(childDirectory.childrenOf(anyLong())).thenReturn(List.of(child)); } private void givenPolicies(Policy... policies) { diff --git a/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java b/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java index 617eac9..9eb22f3 100644 --- a/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java +++ b/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java @@ -10,7 +10,8 @@ import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.domain.user.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -35,7 +36,7 @@ class PolicyDeadlineNotifierTest { private PolicyRepository policyRepository; private UserRepository userRepository; - private ChildRepository childRepository; + private ChildDirectory childDirectory; private PolicyDeadlineNoticeRepository noticeRepository; private PolicyDeadlineNotifier notifier; @@ -43,7 +44,7 @@ class PolicyDeadlineNotifierTest { void setUp() { policyRepository = mock(PolicyRepository.class); userRepository = mock(UserRepository.class); - childRepository = mock(ChildRepository.class); + childDirectory = mock(ChildDirectory.class); noticeRepository = mock(PolicyDeadlineNoticeRepository.class); when(noticeRepository.findNotifiedUserIds(anyLong(), any(LocalDate.class))) .thenReturn(List.of()); @@ -52,7 +53,7 @@ void setUp() { when(notificationRepository.save(any(Notification.class))) .thenAnswer(inv -> inv.getArgument(0)); - notifier = new PolicyDeadlineNotifier(policyRepository, noticeRepository, userRepository, childRepository, + notifier = new PolicyDeadlineNotifier(policyRepository, noticeRepository, userRepository, childDirectory, notificationRepository, mock(NotificationDispatcher.class), mock(EventLogger.class)); ReflectionTestUtils.setField(notifier, "leadDaysRaw", "7,1"); @@ -193,8 +194,8 @@ private void givenUser(User user) { when(userRepository.findByIsActiveTrue()).thenReturn(List.of(user)); } - private void givenChildren(Child... children) { - when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(children)); + private void givenChildren(ChildView... children) { + when(childDirectory.childrenOf(anyLong())).thenReturn(List.of(children)); } private Policy policyDueIn(int days) { @@ -208,11 +209,7 @@ private Policy policyDueIn(int days) { return policy; } - private Child child(int months) { - return Child.builder() - .id(1L) - .name("아이") - .birthDate(LocalDate.now().minusMonths(months)) - .build(); + private ChildView child(int months) { + return new ChildView(1L, "아이", LocalDate.now().minusMonths(months), "FEMALE", null); } } diff --git a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java index 5ebe889..c122fa1 100644 --- a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java @@ -1,7 +1,7 @@ package com.carecode.domain.policy.service; import com.carecode.domain.policy.benefit.BenefitProjectionCalculator; -import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.BusinessException; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; import com.carecode.domain.policy.dto.response.RegionalBenefitResponse; @@ -9,7 +9,8 @@ import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.app.ChildView; import com.carecode.core.analytics.EventLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -28,21 +29,21 @@ class RegionalBenefitComparisonServiceTest { private PolicyRepository policyRepository; - private ChildRepository childRepository; + private ChildDirectory childDirectory; private RegionalBenefitComparisonService service; private User user; @BeforeEach void setUp() { policyRepository = mock(PolicyRepository.class); - childRepository = mock(ChildRepository.class); + childDirectory = mock(ChildDirectory.class); CurrentUserFacade currentUserFacade = mock(CurrentUserFacade.class); user = User.builder().id(1L).name("부모").address("경기도 성남시 분당구").build(); when(currentUserFacade.requireCurrentUser()).thenReturn(user); service = new RegionalBenefitComparisonService( - policyRepository, childRepository, currentUserFacade, mock(EventLogger.class), + policyRepository, childDirectory, currentUserFacade, mock(EventLogger.class), new BenefitProjectionCalculator()); } @@ -231,33 +232,34 @@ void alwaysMarksAsEstimate() { assertThat(result.getDisclaimers()).anyMatch(d -> d.contains("추정치")); } + /** + * 사용자가 고칠 수 있는 상태이므로 400 이어야 한다({@link BusinessException} = + * {@code ErrorCode.INVALID_INPUT}). 전에는 {@code CareServiceException} 이라 500 이 나갔다. + */ @Test - @DisplayName("자녀가 없으면 계산할 수 없다고 알린다") + @DisplayName("자녀가 없으면 400 으로 알린다") void failsWithoutChild() { - when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of()); + when(childDirectory.childrenOf(anyLong())).thenReturn(List.of()); assertThatThrownBy(() -> service.compare(null, 5, 10)) - .isInstanceOf(CareServiceException.class) + .isInstanceOf(BusinessException.class) .hasMessageContaining("등록된 자녀가 없습니다"); } @Test - @DisplayName("생년월일이 없으면 계산할 수 없다고 알린다") + @DisplayName("생년월일이 없으면 400 으로 알린다") void failsWithoutBirthDate() { - Child child = Child.builder().name("아이").birthDate(null).build(); - when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(child)); + ChildView child = new ChildView(1L, "아이", null, "FEMALE", null); + when(childDirectory.childrenOf(anyLong())).thenReturn(List.of(child)); assertThatThrownBy(() -> service.compare(null, 5, 10)) - .isInstanceOf(CareServiceException.class) + .isInstanceOf(BusinessException.class) .hasMessageContaining("생년월일"); } private void givenChildAgedMonths(int months) { - Child child = Child.builder() - .id(1L).name("아이") - .birthDate(LocalDate.now().minusMonths(months)) - .build(); - when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(child)); + ChildView child = new ChildView(1L, "아이", LocalDate.now().minusMonths(months), "FEMALE", null); + when(childDirectory.childrenOf(anyLong())).thenReturn(List.of(child)); } private void givenPolicies(Policy... policies) { diff --git a/src/test/java/com/carecode/domain/user/service/ChildDirectoryServiceTest.java b/src/test/java/com/carecode/domain/user/service/ChildDirectoryServiceTest.java new file mode 100644 index 0000000..f9b9ede --- /dev/null +++ b/src/test/java/com/carecode/domain/user/service/ChildDirectoryServiceTest.java @@ -0,0 +1,128 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.exception.ChildNotFoundException; +import com.carecode.domain.user.app.ChildView; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("자녀 조회 입구") +class ChildDirectoryServiceTest { + + @Mock ChildRepository childRepository; + + private ChildDirectoryService directory; + + @BeforeEach + void setUp() { + directory = new ChildDirectoryService(childRepository); + } + + private User user(long id) { + return User.builder().id(id).build(); + } + + private Child child(long id, long userId, LocalDate birthDate) { + return Child.builder() + .id(id) + .user(user(userId)) + .name("아이" + id) + .birthDate(birthDate) + .build(); + } + + @Test + @DisplayName("사용자의 자녀를 읽기 전용 값으로 돌려준다") + void returnsViewsNotEntities() { + when(childRepository.findByUserIdOrderByCreatedAtDesc(1L)) + .thenReturn(List.of(child(10L, 1L, LocalDate.of(2024, 3, 1)))); + + List children = directory.childrenOf(1L); + + assertThat(children).hasSize(1); + assertThat(children.get(0).childId()).isEqualTo(10L); + assertThat(children.get(0).birthDate()).isEqualTo(LocalDate.of(2024, 3, 1)); + } + + /** + * 남의 자녀와 없는 자녀를 구분해서 알려주면, ID 를 훑어 "이 번호는 누군가의 자녀" 라는 + * 사실을 알아낼 수 있다. 둘 다 같은 예외로 끝나야 한다. + */ + @Test + @DisplayName("남의 자녀도 없는 자녀와 똑같이 '찾을 수 없다' 로 끝난다") + void otherUsersChildIsIndistinguishableFromMissing() { + when(childRepository.findById(10L)).thenReturn(Optional.of(child(10L, 1L, null))); + when(childRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> directory.requireOwnedChild(10L, 2L)) + .as("다른 사용자의 자녀") + .isInstanceOf(ChildNotFoundException.class); + + assertThatThrownBy(() -> directory.requireOwnedChild(99L, 2L)) + .as("아예 없는 자녀") + .isInstanceOf(ChildNotFoundException.class); + } + + @Test + @DisplayName("본인 자녀는 통과한다") + void ownChildPasses() { + when(childRepository.findById(10L)).thenReturn(Optional.of(child(10L, 1L, null))); + + assertThat(directory.requireOwnedChild(10L, 1L).childId()).isEqualTo(10L); + } + + /** 로그인 주체를 못 꺼냈을 때(null) 통과시키면 모든 자녀가 열린다. */ + @Test + @DisplayName("요청자가 없으면 통과시키지 않는다") + void nullActorNeverPasses() { + when(childRepository.findById(10L)).thenReturn(Optional.of(child(10L, 1L, null))); + + assertThatThrownBy(() -> directory.requireOwnedChild(10L, null)) + .isInstanceOf(ChildNotFoundException.class); + } + + @Test + @DisplayName("사용자가 없으면 DB 를 보지 않고 빈 목록을 준다") + void nullUserIdSkipsQuery() { + assertThat(directory.childrenOf(null)).isEmpty(); + } + + /** + * 월령 계산이 정책 추천의 입력이다. 생일이 없을 때 0 을 주면 "갓 태어난 아이" 로 읽혀 + * 신생아 정책이 추천된다. 없는 값은 없는 값으로 둬야 한다. + */ + @Test + @DisplayName("생일이 없으면 월령도 없다 — 0개월로 바꾸지 않는다") + void missingBirthDateHasNoAge() { + ChildView withoutBirthDate = ChildView.from(child(10L, 1L, null)); + assertThat(withoutBirthDate.ageMonths(LocalDate.of(2026, 10, 5))).isNull(); + + ChildView born = ChildView.from(child(11L, 1L, LocalDate.of(2025, 4, 5))); + assertThat(born.ageMonths(LocalDate.of(2026, 10, 5))).isEqualTo(18); + } + + @Test + @DisplayName("특별 고려사항은 빈 문자열과 없음을 같게 본다") + void blankSpecialNeedsIsNone() { + Child blank = Child.builder().id(1L).user(user(1L)).specialNeeds(" ").build(); + Child filled = Child.builder().id(2L).user(user(1L)).specialNeeds("알레르기").build(); + + assertThat(ChildView.from(blank).hasSpecialNeeds()).isFalse(); + assertThat(ChildView.from(filled).hasSpecialNeeds()).isTrue(); + } +} diff --git a/src/test/java/com/carecode/integration/ChildOwnershipContractTest.java b/src/test/java/com/carecode/integration/ChildOwnershipContractTest.java new file mode 100644 index 0000000..6517348 --- /dev/null +++ b/src/test/java/com/carecode/integration/ChildOwnershipContractTest.java @@ -0,0 +1,177 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.core.ops.OperationalAlerter; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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 java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * "이 아이가 내 아이인가" 에 대한 답은 경로마다 같아야 한다. + * + *

이 검증이 세 군데에 각자 쓰여 있었고, 답이 셋으로 갈렸다. + * + *

    + *
  • {@code /children/…} — 404 ({@code ChildNotFoundException}). 맞는 동작이다.
  • + *
  • {@code HealthService.assertChildOwnedByUser} — 403. 자녀가 존재한다는 사실을 알려준다. + * ID 를 1부터 훑으면 "이 번호는 누군가의 자녀" 라는 목록을 만들 수 있다.
  • + *
  • {@code /health/…/schedule} — 500. 위 403 이 {@code catch (Exception e)} 에 걸려 + * {@code CareServiceException} 으로 덮이면서 서버 오류가 됐다. 권한 거부가 장애로 집계되고, + * 클라이언트는 "내가 잘못 요청했다" 를 알 수 없다. (Slack 알림까지 가지는 않았다 — + * 알림은 최후 핸들러에서만 울리고 {@code CareServiceException} 은 자기 핸들러가 있다.)
  • + *
+ * + *

셋 중 404 가 맞다. 없는 자녀와 남의 자녀를 구분해 주지 않는 쪽이다. + */ +@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", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:carecode_child_owner;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 ChildOwnershipContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + @MockBean OperationalAlerter alerter; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired ChildRepository childRepository; + + private User stranger; + private Child child; + + @BeforeEach + void setUp() { + User parent = saveUser(); + stranger = saveUser(); + child = childRepository.save(Child.builder() + .user(parent) + .name("아이") + .birthDate(LocalDate.now().minusMonths(13)) + .gender("FEMALE") + .createdAt(LocalDateTime.now()) + .build()); + } + + /** + * 경로에 {@code %d} 가 있으면 자녀 ID 가 들어간다. + * + *

{@code /health/…} 는 자녀 ID 를 쿼리 파라미터로 받는다 — 같은 질문이지만 모양이 달라 + * 서로 다른 검증을 쓰게 된 원인이기도 하다. + */ + @ParameterizedTest(name = "{0} 은 남의 아이에게 404 다") + @ValueSource(strings = { + "/children/%d", + "/children/%d/timeline", + "/children/%d/vaccinations", + "/children/%d/growth", + "/health/vaccines/schedule?childId=%d", + "/health/checkups/schedule?childId=%d" + }) + void otherParentAlwaysGetsNotFound(String pathTemplate) throws Exception { + String path = pathTemplate.formatted(child.getId()); + + int status = mockMvc.perform(get(path).header("Authorization", "Bearer " + token(stranger))) + .andReturn().getResponse().getStatus(); + + assertThat(status) + .as("%s — 403 이면 자녀의 존재를 알려주고, 500 이면 권한 거부가 장애로 집계된다", path) + .isEqualTo(404); + } + + /** + * 남의 아이 접근은 공격이거나 실수다. 어느 쪽도 운영 알림을 울릴 일이 아니다. + * + *

지금은 울리지 않는다({@code CareServiceException} 에 전용 핸들러가 있어 최후 핸들러까지 + * 가지 않는다). 다만 이 경로가 최후 핸들러로 떨어지게 바뀌면 그때부터 울리므로 여기서 막는다. + */ + @Test + @DisplayName("남의 아이 접근은 운영 알림을 울리지 않는다") + void deniedAccessDoesNotPage() throws Exception { + mockMvc.perform(get("/health/vaccines/schedule?childId=" + child.getId()) + .header("Authorization", "Bearer " + token(stranger))).andReturn(); + + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + + /** 없는 자녀와 남의 자녀의 응답이 같아야 ID 를 훑어도 아무것도 알 수 없다. */ + @Test + @DisplayName("없는 아이와 남의 아이의 응답이 구분되지 않는다") + void missingAndForeignChildLookIdentical() throws Exception { + int foreign = mockMvc.perform(get("/children/{id}", child.getId()) + .header("Authorization", "Bearer " + token(stranger))).andReturn().getResponse().getStatus(); + int missing = mockMvc.perform(get("/children/{id}", 99_999_999L) + .header("Authorization", "Bearer " + token(stranger))).andReturn().getResponse().getStatus(); + + assertThat(foreign).isEqualTo(missing); + } + + private String token(User user) { + return jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + } + + private User saveUser() { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("보호자" + id) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } +}