diff --git a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df index e6bff583..8d3c743d 100644 --- a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df +++ b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df @@ -9,7 +9,6 @@ Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.admin.service.PolicyAdminService)> has parameter of type in (AdminPolicyController.java:0) Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (AdminPolicyVerificationController.java:0) Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.service.UserService)> has parameter of type in (AdminUserController.java:0) -Constructor (com.carecode.external.publicdata.CareFacilityApiService, com.carecode.domain.facility.service.CareFacilityService, com.carecode.domain.facility.repository.CareFacilityRepository)> has parameter of type in (CareFacilityApiController.java:0) Constructor (com.carecode.domain.notification.repository.NotificationRepository, com.carecode.core.analytics.EventLogger, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (NotificationLinkController.java:0) Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.analytics.EventLogger, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (BenefitLinkController.java:0) Field has type in (AdminCommunityController.java:0) @@ -23,7 +22,6 @@ Field has type in (AdminPolicyController.java:0) Field has type in (AdminPolicyVerificationController.java:0) Field has type in (AdminUserController.java:0) -Field has type in (CareFacilityApiController.java:0) Field has type in (NotificationLinkController.java:0) Field has type in (BenefitLinkController.java:0) Method calls method in (AdminCommunityController.java:46) @@ -58,9 +56,6 @@ 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 (CareFacilityApiController.java:86) -Method calls method in (CareFacilityApiController.java:96) -Method calls method in (CareFacilityApiController.java:266) Method calls method in (NotificationLinkController.java:56) Method calls method in (NotificationLinkController.java:64) Method calls method in (BenefitLinkController.java:43) diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index bfa746fb..db6675ba 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -217,15 +217,12 @@ 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:196) -notification → user 리포지토리: Method calls method in (NotificationService.java:238) 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) -notification → user 리포지토리: Method calls method in (NotificationService.java:217) 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) 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) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 59eea87a..997c6157 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1,6 +1,26 @@ { "components" : { "schemas" : { + "AdminBookingDashboardResponse" : { + "properties" : { + "recentBookings" : { + "items" : { + "$ref" : "#/components/schemas/AdminBookingListResponse" + }, + "type" : "array" + }, + "stats" : { + "$ref" : "#/components/schemas/AdminBookingStatsResponse" + }, + "todayBookings" : { + "items" : { + "$ref" : "#/components/schemas/AdminBookingListResponse" + }, + "type" : "array" + } + }, + "type" : "object" + }, "AdminBookingDetailResponse" : { "properties" : { "actualEndTime" : { @@ -235,6 +255,42 @@ }, "type" : "object" }, + "AdminDashboardResponse" : { + "properties" : { + "hospitalCount" : { + "format" : "int64", + "type" : "integer" + }, + "policyCount" : { + "format" : "int64", + "type" : "integer" + }, + "recentActivities" : { + "items" : { + "$ref" : "#/components/schemas/RecentActivity" + }, + "type" : "array" + }, + "userCount" : { + "format" : "int64", + "type" : "integer" + }, + "userTrendData" : { + "items" : { + "format" : "int64", + "type" : "integer" + }, + "type" : "array" + }, + "userTrendLabels" : { + "items" : { + "type" : "string" + }, + "type" : "array" + } + }, + "type" : "object" + }, "AdminNotificationCreateRequest" : { "properties" : { "message" : { @@ -1204,6 +1260,20 @@ "required" : [ "birthDate", "name" ], "type" : "object" }, + "ChildData" : { + "properties" : { + "birthDate" : { + "type" : "string" + }, + "gender" : { + "type" : "string" + }, + "name" : { + "type" : "string" + } + }, + "type" : "object" + }, "ChildInfoResponse" : { "properties" : { "birthDate" : { @@ -1791,6 +1861,19 @@ }, "type" : "object" }, + "DeadlineNotifyResponse" : { + "properties" : { + "notificationsSent" : { + "format" : "int32", + "type" : "integer" + }, + "policiesDueSoon" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, "FacilityDistribution" : { "properties" : { "count" : { @@ -1864,6 +1947,21 @@ }, "type" : "object" }, + "ForecastAccuracyMeasureResponse" : { + "properties" : { + "measured" : { + "format" : "int32", + "type" : "integer" + }, + "results" : { + "items" : { + "$ref" : "#/components/schemas/ForecastAccuracyRow" + }, + "type" : "array" + } + }, + "type" : "object" + }, "ForecastAccuracyResponse" : { "properties" : { "actualRate" : { @@ -1909,6 +2007,38 @@ }, "type" : "object" }, + "ForecastAccuracyRow" : { + "properties" : { + "actualRate" : { + "format" : "double", + "type" : "number" + }, + "baselineBrierScore" : { + "format" : "double", + "type" : "number" + }, + "betterThanBaseline" : { + "type" : "boolean" + }, + "brierScore" : { + "format" : "double", + "type" : "number" + }, + "facilities" : { + "format" : "int32", + "type" : "integer" + }, + "horizonMonths" : { + "format" : "int32", + "type" : "integer" + }, + "samples" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, "FunnelResponse" : { "properties" : { "from" : { @@ -1928,6 +2058,26 @@ }, "type" : "object" }, + "GeocodingResultResponse" : { + "properties" : { + "failed" : { + "format" : "int32", + "type" : "integer" + }, + "remaining" : { + "format" : "int64", + "type" : "integer" + }, + "resolved" : { + "format" : "int32", + "type" : "integer" + }, + "skippedReason" : { + "type" : "string" + } + }, + "type" : "object" + }, "GrowthPointResponse" : { "properties" : { "ageMonths" : { @@ -2096,6 +2246,33 @@ "required" : [ "content", "hospitalId", "rating" ], "type" : "object" }, + "HealthRecommendationResponse" : { + "properties" : { + "childAge" : { + "format" : "int32", + "type" : "integer" + }, + "nudgeMessage" : { + "type" : "string" + }, + "recommendedFacilities" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "recommendedPolicies" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "userId" : { + "type" : "string" + } + }, + "type" : "object" + }, "HealthRecordAttachmentRequest" : { "properties" : { "description" : { @@ -2473,9 +2650,70 @@ }, "type" : "object" }, + "JobFreshness" : { + "properties" : { + "ageHours" : { + "format" : "int64", + "type" : "integer" + }, + "dataFreshness" : { + "type" : "boolean" + }, + "job" : { + "type" : "string" + }, + "label" : { + "type" : "string" + }, + "lastDetail" : { + "type" : "string" + }, + "lastFailed" : { + "format" : "int32", + "type" : "integer" + }, + "lastFinishedAt" : { + "format" : "date-time", + "type" : "string" + }, + "lastFreshAt" : { + "format" : "date-time", + "type" : "string" + }, + "lastProcessed" : { + "format" : "int32", + "type" : "integer" + }, + "lastStatus" : { + "type" : "string" + }, + "stale" : { + "type" : "boolean" + }, + "staleAfterHours" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, "JsonNode" : { "type" : "object" }, + "KakaoLoginUrlResponse" : { + "properties" : { + "loginUrl" : { + "type" : "string" + }, + "message" : { + "type" : "string" + }, + "success" : { + "type" : "boolean" + } + }, + "type" : "object" + }, "KakaoRegistrationRequest" : { "description" : "가입 완료 정보", "properties" : { @@ -2583,6 +2821,38 @@ }, "type" : "object" }, + "MyDataExportResponse" : { + "properties" : { + "children" : { + "items" : { + "$ref" : "#/components/schemas/ChildData" + }, + "type" : "array" + }, + "consentHistory" : { + "items" : { + "$ref" : "#/components/schemas/ConsentHistoryItem" + }, + "type" : "array" + }, + "exportedAt" : { + "format" : "date-time", + "type" : "string" + }, + "healthRecordCount" : { + "format" : "int32", + "type" : "integer" + }, + "postCount" : { + "format" : "int64", + "type" : "integer" + }, + "profile" : { + "$ref" : "#/components/schemas/Profile" + } + }, + "type" : "object" + }, "NotificationChannelStatusResponse" : { "properties" : { "available" : { @@ -3507,48 +3777,182 @@ }, "type" : "object" }, - "ProfileImageResponse" : { - "properties" : { - "profileImageUrl" : { - "type" : "string" - } - }, - "type" : "object" - }, - "PublicDataResponseObject" : { + "PolicyVerificationResponse" : { "properties" : { - "data" : { - "items" : { - "type" : "object" - }, - "type" : "array" + "policyId" : { + "format" : "int64", + "type" : "integer" }, - "errorMessage" : { + "title" : { "type" : "string" }, - "response" : { - "$ref" : "#/components/schemas/ResponseObject" - }, - "success" : { - "type" : "boolean" + "verifiedAt" : { + "format" : "date-time", + "type" : "string" }, - "totalCount" : { - "format" : "int32", - "type" : "integer" + "verifiedBy" : { + "type" : "string" } }, "type" : "object" }, - "RefreshTokenRequest" : { - "description" : "토큰 갱신 정보 (쿠키를 쓰는 경우 생략 가능)", + "PostBookmarkCountResponse" : { "properties" : { - "refreshToken" : { - "type" : "string" + "bookmarkCount" : { + "format" : "int64", + "type" : "integer" } }, "type" : "object" }, - "RegionalBenefitComparisonResponse" : { + "PostBookmarkToggleResponse" : { + "properties" : { + "bookmarkCount" : { + "format" : "int64", + "type" : "integer" + }, + "isBookmarked" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "PostLikeCountResponse" : { + "properties" : { + "likeCount" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, + "PostLikeToggleResponse" : { + "properties" : { + "isLiked" : { + "type" : "boolean" + }, + "likeCount" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, + "Profile" : { + "properties" : { + "address" : { + "type" : "string" + }, + "birthDate" : { + "format" : "date", + "type" : "string" + }, + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "lastLoginAt" : { + "format" : "date-time", + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "phoneNumber" : { + "type" : "string" + }, + "role" : { + "type" : "string" + }, + "userId" : { + "type" : "string" + } + }, + "type" : "object" + }, + "ProfileImageResponse" : { + "properties" : { + "profileImageUrl" : { + "type" : "string" + } + }, + "type" : "object" + }, + "PublicDataResponseObject" : { + "properties" : { + "data" : { + "items" : { + "type" : "object" + }, + "type" : "array" + }, + "errorMessage" : { + "type" : "string" + }, + "response" : { + "$ref" : "#/components/schemas/ResponseObject" + }, + "success" : { + "type" : "boolean" + }, + "totalCount" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, + "RecentActivity" : { + "properties" : { + "desc" : { + "type" : "string" + }, + "time" : { + "type" : "string" + }, + "type" : { + "type" : "string" + } + }, + "type" : "object" + }, + "RefreshTokenRequest" : { + "description" : "토큰 갱신 정보 (쿠키를 쓰는 경우 생략 가능)", + "properties" : { + "refreshToken" : { + "type" : "string" + } + }, + "type" : "object" + }, + "RegionVerificationStatusResponse" : { + "properties" : { + "region" : { + "type" : "string" + }, + "total" : { + "format" : "int32", + "type" : "integer" + }, + "unverified" : { + "format" : "int64", + "type" : "integer" + }, + "verified" : { + "format" : "int64", + "type" : "integer" + }, + "verifiedRate" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, + "RegionalBenefitComparisonResponse" : { "properties" : { "baseAmount" : { "format" : "int64", @@ -3884,6 +4288,58 @@ }, "type" : "object" }, + "SyncResultResponse" : { + "properties" : { + "completed" : { + "type" : "boolean" + }, + "created" : { + "format" : "int32", + "type" : "integer" + }, + "failed" : { + "format" : "int32", + "type" : "integer" + }, + "pagesProcessed" : { + "format" : "int32", + "type" : "integer" + }, + "provider" : { + "type" : "string" + }, + "resource" : { + "type" : "string" + }, + "skipped" : { + "format" : "int32", + "type" : "integer" + }, + "stoppedReason" : { + "type" : "string" + }, + "updated" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, + "SyncStatusResponse" : { + "properties" : { + "jobs" : { + "items" : { + "$ref" : "#/components/schemas/JobFreshness" + }, + "type" : "array" + }, + "staleCount" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, "TimelineItem" : { "properties" : { "ageMonths" : { @@ -4235,6 +4691,23 @@ "required" : [ "name" ], "type" : "object" }, + "VacancyNotifyResponse" : { + "properties" : { + "facilitiesChecked" : { + "format" : "int32", + "type" : "integer" + }, + "facilitiesWithVacancy" : { + "format" : "int32", + "type" : "integer" + }, + "notificationsSent" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, "VaccinationScheduleResponse" : { "properties" : { "childId" : { @@ -4303,6 +4776,46 @@ }, "type" : "object" }, + "WaitlistEntryResponse" : { + "properties" : { + "appliedAt" : { + "format" : "date", + "type" : "string" + }, + "facilityId" : { + "format" : "int64", + "type" : "integer" + }, + "status" : { + "type" : "string" + }, + "statusName" : { + "type" : "string" + }, + "waitNumber" : { + "format" : "int32", + "type" : "integer" + }, + "waitedDays" : { + "format" : "int64", + "type" : "integer" + }, + "waitlistId" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, + "WaitlistRegisterResponse" : { + "properties" : { + "waitlistId" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, "WaitlistRequest" : { "properties" : { "appliedAt" : { @@ -4719,10 +5232,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/AdminDashboardResponse" } } }, @@ -4887,10 +5397,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/AdminBookingDashboardResponse" } } }, @@ -5634,10 +6141,7 @@ "application/json" : { "schema" : { "items" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/RegionVerificationStatusResponse" }, "type" : "array" } @@ -5872,10 +6376,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/PolicyVerificationResponse" } } }, @@ -5905,10 +6406,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/SyncResultResponse" } } }, @@ -5938,10 +6436,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/GeocodingResultResponse" } } }, @@ -5971,10 +6466,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/VacancyNotifyResponse" } } }, @@ -6004,10 +6496,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/SyncResultResponse" } } }, @@ -6037,10 +6526,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/SyncResultResponse" } } }, @@ -6070,10 +6556,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/SyncResultResponse" } } }, @@ -6103,10 +6586,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/DeadlineNotifyResponse" } } }, @@ -6227,10 +6707,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/ForecastAccuracyMeasureResponse" } } }, @@ -6260,10 +6737,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/SyncStatusResponse" } } }, @@ -6883,173 +7357,10 @@ "in" : "query", "name" : "numOfRows", "required" : false, - "schema" : { - "default" : 10, - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/PublicDataResponseObject" - } - } - }, - "description" : "OK" - } - }, - "summary" : "육아 시설 정보 조회", - "tags" : [ "공공데이터 API" ] - } - }, - "/api/public-data/childcare-policies" : { - "get" : { - "operationId" : "getChildcarePolicies", - "parameters" : [ { - "description" : "정책 유형", - "in" : "query", - "name" : "policyType", - "required" : false, - "schema" : { - "type" : "string" - } - }, { - "description" : "페이지 번호", - "in" : "query", - "name" : "pageNo", - "required" : false, - "schema" : { - "default" : 1, - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "페이지당 행 수", - "in" : "query", - "name" : "numOfRows", - "required" : false, - "schema" : { - "default" : 10, - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/PublicDataResponseObject" - } - } - }, - "description" : "OK" - } - }, - "summary" : "보육 정책 정보 조회", - "tags" : [ "공공데이터 API" ] - } - }, - "/api/public-data/childcare-subsidies" : { - "get" : { - "operationId" : "getChildcareSubsidies", - "parameters" : [ { - "description" : "지역명", - "in" : "query", - "name" : "region", - "required" : false, - "schema" : { - "type" : "string" - } - }, { - "description" : "페이지 번호", - "in" : "query", - "name" : "pageNo", - "required" : false, - "schema" : { - "default" : 1, - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "페이지당 행 수", - "in" : "query", - "name" : "numOfRows", - "required" : false, - "schema" : { - "default" : 10, - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/PublicDataResponseObject" - } - } - }, - "description" : "OK" - } - }, - "summary" : "육아 지원금 정보 조회", - "tags" : [ "공공데이터 API" ] - } - }, - "/api/public-data/custom/{endpoint}" : { - "get" : { - "description" : "사용자 정의 엔드포인트로 공공데이터 API를 호출", - "operationId" : "callCustomApi", - "parameters" : [ { - "description" : "API 엔드포인트", - "in" : "path", - "name" : "endpoint", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "쿼리 파라미터", - "in" : "query", - "name" : "params", - "required" : true, - "schema" : { - "additionalProperties" : { - "type" : "string" - }, - "type" : "object" + "schema" : { + "default" : 10, + "format" : "int32", + "type" : "integer" } }, { "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", @@ -7073,17 +7384,17 @@ "description" : "OK" } }, - "summary" : "커스텀 API 호출", + "summary" : "육아 시설 정보 조회", "tags" : [ "공공데이터 API" ] } }, - "/api/public-data/pediatric-hospitals" : { + "/api/public-data/childcare-policies" : { "get" : { - "operationId" : "getPediatricHospitals", + "operationId" : "getChildcarePolicies", "parameters" : [ { - "description" : "지역명", + "description" : "정책 유형", "in" : "query", - "name" : "region", + "name" : "policyType", "required" : false, "schema" : { "type" : "string" @@ -7130,29 +7441,35 @@ "description" : "OK" } }, - "summary" : "소아과 병원 정보 조회", + "summary" : "보육 정책 정보 조회", "tags" : [ "공공데이터 API" ] } }, - "/api/public/care-facilities/swagger/db-facilities" : { + "/api/public-data/childcare-subsidies" : { "get" : { - "operationId" : "swaggerGetDbFacilities", + "operationId" : "getChildcareSubsidies", "parameters" : [ { + "description" : "지역명", + "in" : "query", + "name" : "region", + "required" : false, + "schema" : { + "type" : "string" + } + }, { "description" : "페이지 번호", - "example" : 0, "in" : "query", - "name" : "page", + "name" : "pageNo", "required" : false, "schema" : { - "default" : 0, + "default" : 1, "format" : "int32", "type" : "integer" } }, { - "description" : "한 페이지 결과 수", - "example" : 10, + "description" : "페이지당 행 수", "in" : "query", - "name" : "size", + "name" : "numOfRows", "required" : false, "schema" : { "default" : 10, @@ -7174,58 +7491,41 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/PublicDataResponseObject" } } }, "description" : "OK" } }, - "summary" : "DB 저장된 보육시설 목록", - "tags" : [ "돌봄시설 공공데이터" ] + "summary" : "육아 지원금 정보 조회", + "tags" : [ "공공데이터 API" ] } }, - "/api/public/care-facilities/swagger/stats" : { + "/api/public-data/custom/{endpoint}" : { "get" : { - "description" : "DB에 저장된 보육시설 통계 정보 조회", - "operationId" : "swaggerGetStats", + "description" : "사용자 정의 엔드포인트로 공공데이터 API를 호출", + "operationId" : "callCustomApi", "parameters" : [ { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, + "description" : "API 엔드포인트", + "in" : "path", + "name" : "endpoint", + "required" : true, "schema" : { - "default" : "1", "type" : "string" } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } + }, { + "description" : "쿼리 파라미터", + "in" : "query", + "name" : "params", + "required" : true, + "schema" : { + "additionalProperties" : { + "type" : "string" }, - "description" : "OK" + "type" : "object" } - }, - "summary" : "보육시설 통계", - "tags" : [ "돌봄시설 공공데이터" ] - } - }, - "/api/public/care-facilities/swagger/sync" : { - "get" : { - "description" : "Swagger UI에서 쉽게 테스트할 수 있는 GET 방식 동기화", - "operationId" : "swaggerSync", - "parameters" : [ { + }, { "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", "in" : "header", "name" : "X-API-Version", @@ -7240,52 +7540,45 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/PublicDataResponseObject" } } }, "description" : "OK" } }, - "summary" : "Swagger용 보육시설 동기화", - "tags" : [ "돌봄시설 공공데이터" ] + "summary" : "커스텀 API 호출", + "tags" : [ "공공데이터 API" ] } }, - "/api/public/care-facilities/sync-all" : { - "post" : { - "description" : "공공데이터에서 모든 보육시설 정보를 조회하고 DB에 저장", - "operationId" : "syncAllChildcareFacilities", + "/api/public-data/pediatric-hospitals" : { + "get" : { + "operationId" : "getPediatricHospitals", "parameters" : [ { - "description" : "시도명", - "example" : "서울특별시", + "description" : "지역명", "in" : "query", - "name" : "sido", + "name" : "region", "required" : false, "schema" : { - "default" : "서울특별시", "type" : "string" } }, { - "description" : "시군구명", - "example" : "강남구", + "description" : "페이지 번호", "in" : "query", - "name" : "sigungu", + "name" : "pageNo", "required" : false, "schema" : { - "default" : "강남구", - "type" : "string" + "default" : 1, + "format" : "int32", + "type" : "integer" } }, { - "description" : "한 페이지 결과 수", - "example" : 100, + "description" : "페이지당 행 수", "in" : "query", "name" : "numOfRows", "required" : false, "schema" : { - "default" : 100, + "default" : 10, "format" : "int32", "type" : "integer" } @@ -7304,18 +7597,15 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/PublicDataResponseObject" } } }, "description" : "OK" } }, - "summary" : "전체 보육시설 정보 동기화", - "tags" : [ "돌봄시설 공공데이터" ] + "summary" : "소아과 병원 정보 조회", + "tags" : [ "공공데이터 API" ] } }, "/auth/kakao/complete-registration" : { @@ -7415,10 +7705,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/KakaoLoginUrlResponse" } } }, @@ -9335,10 +9622,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/PostBookmarkToggleResponse" } } }, @@ -9377,11 +9661,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "format" : "int64", - "type" : "integer" - }, - "type" : "object" + "$ref" : "#/components/schemas/PostBookmarkCountResponse" } } }, @@ -9509,10 +9789,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/PostLikeToggleResponse" } } }, @@ -9551,11 +9828,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "format" : "int64", - "type" : "integer" - }, - "type" : "object" + "$ref" : "#/components/schemas/PostLikeCountResponse" } } }, @@ -10766,10 +11039,7 @@ "application/json" : { "schema" : { "items" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/WaitlistEntryResponse" }, "type" : "array" } @@ -11102,10 +11372,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/WaitlistRegisterResponse" } } }, @@ -12392,10 +12659,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/HealthRecommendationResponse" } } }, @@ -13886,140 +14150,6 @@ "tags" : [ "알림 관리" ] } }, - "/notifications/settings/{userId}" : { - "get" : { - "operationId" : "getNotificationSettings", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "path", - "name" : "userId", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "알림 설정 조회", - "tags" : [ "알림 관리" ] - }, - "put" : { - "description" : "사용자의 알림 설정을 업데이트", - "operationId" : "updateNotificationSettings", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "path", - "name" : "userId", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "description" : "알림 설정", - "type" : "object" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "알림 설정 업데이트", - "tags" : [ "알림 관리" ] - } - }, - "/notifications/statistics/{userId}" : { - "get" : { - "description" : "사용자의 알림 관련 통계 조회", - "operationId" : "getNotificationStatistics", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "path", - "name" : "userId", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "알림 통계 조회", - "tags" : [ "알림 관리" ] - } - }, "/notifications/stats" : { "get" : { "operationId" : "getNotificationStats", @@ -15866,10 +15996,7 @@ "content" : { "application/json" : { "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" + "$ref" : "#/components/schemas/MyDataExportResponse" } } }, @@ -16237,9 +16364,6 @@ }, { "description" : "공공데이터 포털 API 호출", "name" : "공공데이터 API" - }, { - "description" : "공공데이터 포털의 돌봄시설 정보 API", - "name" : "돌봄시설 공공데이터" }, { "description" : "본인 계정 관리 API (프로필, 위치, 탈퇴)", "name" : "사용자" diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index a6887c7b..71a4f563 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -31,17 +31,20 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. ## 규칙과 현재 위반 -진척: **489건(0단계) → 391건(1b 단계)**. 남은 것은 도메인 경계(271)와 컨트롤러의 리포지토리 -직접 사용(66), core 기반의 도메인 참조(50)입니다. +진척: **489건(0단계) → 391건(1b 단계) → 383건(3a 단계)**. 남은 것은 도메인 경계(268)와 +컨트롤러의 리포지토리 직접 사용(61), core 기반의 도메인 참조(50)입니다. + +> 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다. +> `cat archunit-baseline/* | wc -l` 은 색인 11줄을 함께 세므로 실제 위반보다 11 크게 나옵니다. | 규칙 | 위반 | 뜻 | |------|------|-----| -| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 271 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | +| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 268 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | | 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) | -| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 66 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | +| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 61 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | | core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 | | 매퍼는 리포지토리를 쓰지 않는다 | 3 | 변환 한 번에 숨은 쿼리가 생기면 N+1 을 추적할 수 없다 | -| core 에는 컨트롤러가 없다 | 2 | `core/controller/CareFacilityApiController` 373줄 | +| core 에는 컨트롤러가 없다 | **0** | 1a 에서 도메인으로 옮기고, 3a 에서 중복이라 삭제 | | 도메인은 SecurityContextHolder 를 직접 쓰지 않는다 | 1 | principal 모양이 바뀌면 고칠 곳이 흩어진다(시설 API 500 의 원인) | | 서비스·리포지토리는 컨트롤러를 의존하지 않는다 | **0** | 층 방향은 이미 지켜지고 있다 | | 엔티티는 DTO·컨트롤러를 의존하지 않는다 | **0** | 매핑 경계는 지켜지고 있다 | @@ -90,13 +93,84 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. 이동 PR 에서는 `freeze.refreeze=true` 로 한 번 다시 뜨고, 규칙별 숫자가 늘지 않았는지 확인한 뒤 다시 `false` 로 돌려놓습니다(그래야 CI 가 진짜 새 위반을 잡습니다). +## 같은 일을 하는 입구를 지웠다 (3a 단계) + +`Map` 응답 24곳을 타입으로 바꾸려고 하나씩 읽다가, 그중 7곳은 **타입을 붙일 값이 +아니라는 것**을 알았습니다. 이미 같은 일을 하는 다른 엔드포인트가 있었습니다. + +| 지운 것 | 같은 일을 하는, 남겨 둔 것 | 왜 지웠나 | +|---------|---------------------------|-----------| +| `POST /api/public/care-facilities/sync-all` | `POST /api/admin/public-data/facilities/sync` | 컨트롤러가 리포지토리에 직접 쓰고 페이지 루프를 돌렸다. 남긴 쪽은 `SyncResult` 와 신선도 이력을 남긴다 | +| `GET /api/public/care-facilities/swagger/sync` | 같음 | 위를 GET 으로 한 번 더 노출한 것. **GET 이라 브라우저 접속만으로 DB 쓰기가 실행됐다** | +| `GET /api/public/care-facilities/swagger/db-facilities` | `GET /facilities` | 응답을 `success`/`message` 로 한 겹 더 싸기만 했다 | +| `GET /api/public/care-facilities/swagger/stats` | `GET /facilities/statistics` | 같음 | +| `GET /notifications/settings/{userId}` | `GET /notifications/preferences` | 고정값을 돌려줬다 (이메일 켬, 조용한 시간 22:00–08:00) | +| `PUT /notifications/settings/{userId}` | `PUT /notifications/preferences/...` | **받은 값을 그대로 돌려주고 저장하지 않았다** | +| `GET /notifications/statistics/{userId}` | `GET /notifications/stats` | 우선순위 분포 `HIGH=5, NORMAL=15, LOW=5`, 일별 건수 `2024-01-15: 3` 이 코드에 박혀 있었다 | + +마지막 세 개가 이 단계의 핵심입니다. 응답이 그럴듯하면 프런트는 **설정이 저장된다고 읽습니다.** +타입을 붙이면 그 거짓이 스펙에 정식으로 올라가므로, 타입화가 아니라 삭제가 맞습니다. + +입구가 둘이면 보안 설정도 둘입니다. 실제로 `/api/public/care-facilities/**` 가 통째로 `permitAll` +이어서, 그 아래 동기화 트리거까지 열려 있었고 경로별로 `hasRole("ADMIN")` 을 덧붙여 막아 둔 +상태였습니다. 중복을 지우면서 그 예외 설정도 함께 사라졌습니다. + +되살아나는 것을 막으려고 `AccessControlContractTest.duplicateMappingsRemoved` 가 일곱 경로를 +모두 두드려 404/405 를 확인합니다(복사·붙여넣기로 돌아오기 쉬운 종류입니다). +OpenAPI 스펙은 237 → 231 경로가 됐습니다. + +## `Map` 응답을 타입으로 바꿨다 (3b 단계) + +컨트롤러가 `Map` 를 돌려주면 OpenAPI 에는 `type: object` 만 남습니다. 키도 타입도 +스펙에 없으니, 공들여 만든 프런트 계약 대조(`openapi-contract.test.ts`)가 그 경로에서는 **경로가 +있는지만** 확인하고 응답은 아무것도 확인하지 못합니다. + +3a 에서 7곳을 지우고 남은 17곳을 레코드로 바꿨습니다. + +| 어디 | DTO | +|------|-----| +| 대기 등록·내 대기 목록 | `WaitlistRegisterResponse`, `WaitlistEntryResponse` | +| 좋아요·북마크 토글·개수 | `PostLikeToggleResponse`, `PostBookmarkToggleResponse`, `PostLikeCountResponse`, `PostBookmarkCountResponse` | +| 카카오 로그인 URL | `KakaoLoginUrlResponse` | +| 연계 추천 | `HealthRecommendationResponse` | +| 공공데이터 동기화 4종·좌표 보정·빈자리/마감 알림 | `SyncResultResponse`, `GeocodingResultResponse`, `VacancyNotifyResponse`, `DeadlineNotifyResponse` | +| 주기 작업 상태·예측 정확도 측정 | `SyncStatusResponse`, `ForecastAccuracyMeasureResponse` | +| 정책 검증·지역별 검증 현황 | `PolicyVerificationResponse`, `RegionVerificationStatusResponse` | +| 어드민 대시보드·예약 대시보드 | `AdminDashboardResponse`, `AdminBookingDashboardResponse` | +| 내 데이터 내려받기 | `MyDataExportResponse` | + +### 바꾸면서 깨질 수 있는 지점은 키 이름이다 + +`Map.put("isLiked", ...)` 는 키를 그대로 쓰지만, 같은 값을 Lombok `@Getter` 클래스의 +`boolean isLiked` 로 담으면 Jackson 이 `is` 를 떼어 **키가 `liked` 로 나갑니다.** 프런트는 +`isLiked` 를 `z.boolean()` 으로 필수로 읽으므로 그 순간 토글이 파싱 단계에서 깨집니다. +이 프로젝트는 같은 사고를 `isRead`·`isAnonymous`·`zScore` 로 이미 겪었습니다. + +레코드는 컴포넌트 이름을 그대로 키로 쓰므로 지금은 문제가 없습니다(실제로 확인했습니다 — +`{"isLiked":true,"likeCount":7}`). 다만 나중에 클래스로 바꾸면 되살아나므로 `@JsonProperty` 를 +명시해 두었고, `JsonFieldNameContractTest` 가 **바꾼 응답 전부의 키 집합**을 고정합니다. +키가 빠지는 것뿐 아니라 **늘어나는 것도** 계약 변경이므로 `containsExactlyInAnyOrder` 로 봅니다. + +### 한 군데는 값도 고쳤다 + +내 데이터 내려받기의 자녀 생일·성별이 `String.valueOf()` 를 거쳐, 값이 없으면 `"null"` 이라는 +**문자열**로 내려갔습니다. 열람권 행사로 받은 파일에 `"null"` 이 적혀 있는 셈입니다. +이제 없는 값은 `null` 로 보냅니다(프런트는 받은 JSON 을 그대로 파일로 저장합니다). + +### 숫자 + +- OpenAPI 스키마: 134 → **158** 개 (경로 수는 그대로 231) +- 프런트가 `z.record(z.unknown())` 으로 받던 내려받기 응답에 처음으로 모양이 생겼다 +- 전체 테스트 **616개, 실패 0** + ## 다음 단계 | 단계 | 내용 | 효과 | |------|------|------| | ~~1a. core 정리~~ | ~~조합 계층 분리, core 의 도메인 코드 이동~~ | **완료** — core 에 컨트롤러 0 | | ~~1b. 이름 통일~~ | ~~`careFacility`→`facility`, 문서의 `facade`↔코드 `app`~~ | **완료** — 489 → 391 | -| 2. 예외 한 체계 | `catch(Exception)` 덮어쓰기 제거, ErrorCode 기반 통합 | 상태 코드 정확해짐 | -| 3. `Map` 응답 → DTO | 24곳. 스펙에 타입이 생겨 프런트 계약 대조가 이 경로까지 본다 | 계약 품질 | +| ~~2. 예외 한 체계~~ | ~~"찾을 수 없습니다" 34곳을 `ResourceNotFoundException` 으로~~ | **완료** — 500 → 404 | +| ~~3a. 중복 입구 제거~~ | ~~같은 일을 하는 엔드포인트 7개 삭제~~ | **완료** — 391 → 383 | +| ~~3b. `Map` 응답 → DTO~~ | ~~남은 17곳~~ | **완료** — 스펙 스키마 134 → 158 | | 4. Child 를 제 자리로 | 엔티티는 `user`, 서비스는 `health` 에 쪼개져 있다 | 결합 1위 해소 | | 5. 거대 서비스 분해 | HealthService 977줄 | 2·3 과 함께 진행 | diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 838d9a08..a3ef8351 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -166,16 +166,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 돌봄시설 공공데이터 API — 조회만 공개다. // - // 동기화는 외부 공공데이터 API 를 페이지 단위로 호출하고 DB 에 쓴다. - // 공개로 두면 누구나 공공데이터 일일 한도를 태우고 DB 를 두드릴 수 있다. - // (이 프로젝트는 "공공데이터 한도 초과" 를 운영 알림으로 잡고 있는데, - // 그 상황을 외부에서 마음대로 만들 수 있는 셈이다.) - // swagger/sync 는 GET 이라 브라우저 접속이나 크롤러만으로도 실행된다. - // - // 같은 기능이 POST /api/admin/public-data/facilities/sync 로 이미 있다. - .requestMatchers("/api/public/care-facilities/sync-all").hasRole("ADMIN") - .requestMatchers("/api/public/care-facilities/swagger/sync").hasRole("ADMIN") - .requestMatchers("/api/public/care-facilities/**").permitAll() + // 동기화 트리거가 이 아래에도 있었다. /api/public/care-facilities/** 가 통째로 + // permitAll 이라 누구나 공공데이터 일일 한도를 태우고 DB 에 쓸 수 있었고, + // 경로마다 hasRole("ADMIN") 을 덧붙여 막아 두었다. 그 경로들은 어드민 동기화 + // (POST /api/admin/public-data/facilities/sync) 와 시설 조회(/facilities)의 + // 중복이어서 삭제했고, 덧붙였던 예외도 함께 지웠다. + // 입구가 하나면 보안 설정도 한 군데서 끝난다. // 병원 조회는 로그인 전에도 보여야 한다. 실제 경로가 /health/hospitals/** 라 // 아래 /health/** 규칙보다 먼저 선언해야 한다. diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java b/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java index 5450c0dd..b5d31b61 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java @@ -4,6 +4,7 @@ import com.carecode.core.web.BaseController; import com.carecode.domain.admin.dto.AdminBookingDetailResponse; import com.carecode.domain.admin.dto.AdminBookingSearchRequest; +import com.carecode.domain.admin.dto.response.AdminBookingDashboardResponse; import com.carecode.domain.admin.dto.AdminBookingSearchResponse; import com.carecode.domain.admin.dto.AdminBookingStatsResponse; import com.carecode.domain.admin.dto.AdminStatusUpdateRequest; @@ -17,7 +18,6 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.Map; /** 관리자용 육아 시설 예약 관리 API. 접근 제어는 SecurityConfig 의 /api/admin/** → hasRole("ADMIN") 규칙이 담당한다. */ @Slf4j @@ -129,17 +129,16 @@ public ResponseEntity statusBookings( @GetMapping("/dashboard") @LogExecutionTime @Operation(summary = "예약 대시보드 요약", description = "통계, 최근 예약, 오늘의 예약을 함께 반환") - public ResponseEntity> bookingDashboard() { + public ResponseEntity bookingDashboard() { AdminBookingSearchRequest recentRequest = AdminBookingSearchRequest.builder() .page(0) .size(10) .build(); - return ResponseEntity.ok(Map.of( - "stats", adminBookingService.getBookingStats(), - "recentBookings", adminBookingService.searchBookings(recentRequest).getBookings(), - "todayBookings", adminBookingService.searchBookings(todayRequest(0, 5)).getBookings() - )); + return ResponseEntity.ok(new AdminBookingDashboardResponse( + adminBookingService.getBookingStats(), + adminBookingService.searchBookings(recentRequest).getBookings(), + adminBookingService.searchBookings(todayRequest(0, 5)).getBookings())); } private AdminBookingSearchRequest todayRequest(Integer page, Integer size) { diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java b/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java index 75180473..397fce89 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java @@ -2,6 +2,8 @@ import com.carecode.domain.health.entity.Hospital; import com.carecode.domain.health.repository.HospitalRepository; +import com.carecode.domain.admin.dto.response.AdminDashboardResponse; +import com.carecode.domain.admin.dto.response.AdminDashboardResponse.RecentActivity; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.user.entity.User; @@ -36,48 +38,33 @@ public class AdminDashboardController { @GetMapping("/dashboard") @Operation(summary = "대시보드 요약 조회", description = "전체 건수, 최근 활동, 가입자 추이 반환") - public ResponseEntity> dashboard() { - Map dashboard = new LinkedHashMap<>(); - - dashboard.put("userCount", userRepository.count()); - dashboard.put("hospitalCount", hospitalRepository.count()); - dashboard.put("policyCount", policyRepository.count()); - dashboard.put("recentActivities", recentActivities()); - + public ResponseEntity dashboard() { Map userTrend = userTrend(); - dashboard.put("userTrendLabels", new ArrayList<>(userTrend.keySet())); - dashboard.put("userTrendData", new ArrayList<>(userTrend.values())); - return ResponseEntity.ok(dashboard); + return ResponseEntity.ok(new AdminDashboardResponse( + userRepository.count(), + hospitalRepository.count(), + policyRepository.count(), + recentActivities(), + new ArrayList<>(userTrend.keySet()), + new ArrayList<>(userTrend.values()))); } - private List> recentActivities() { - List> activities = new ArrayList<>(); + private List recentActivities() { + List activities = new ArrayList<>(); for (User user : userRepository.findTop2ByDeletedAtIsNullOrderByCreatedAtDesc()) { - activities.add(Map.of( - "type", "user", - "desc", "신규 사용자 가입: " + user.getName(), - "time", format(user.getCreatedAt()) - )); + activities.add(new RecentActivity("user", "신규 사용자 가입: " + user.getName(), format(user.getCreatedAt()))); } for (Hospital hospital : hospitalRepository.findTop2ByOrderByCreatedAtDesc()) { - activities.add(Map.of( - "type", "hospital", - "desc", "병원 등록: " + hospital.getName(), - "time", format(hospital.getCreatedAt()) - )); + activities.add(new RecentActivity("hospital", "병원 등록: " + hospital.getName(), format(hospital.getCreatedAt()))); } for (Policy policy : policyRepository.findTop1ByOrderByCreatedAtDesc()) { - activities.add(Map.of( - "type", "policy", - "desc", "정책 등록: " + policy.getTitle(), - "time", format(policy.getCreatedAt()) - )); + activities.add(new RecentActivity("policy", "정책 등록: " + policy.getTitle(), format(policy.getCreatedAt()))); } return activities.stream() - .sorted(Comparator.comparing((Map m) -> m.get("time")).reversed()) + .sorted(Comparator.comparing(RecentActivity::time).reversed()) .limit(5) .collect(Collectors.toList()); } diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java index be07dd99..d31a40ea 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java @@ -2,6 +2,8 @@ import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.admin.dto.response.PolicyVerificationResponse; +import com.carecode.domain.admin.dto.response.RegionVerificationStatusResponse; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.PolicyRepository; import io.swagger.v3.oas.annotations.Operation; @@ -15,7 +17,6 @@ import java.time.LocalDateTime; import java.util.Comparator; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -34,7 +35,7 @@ public class AdminPolicyVerificationController { @PostMapping("/{policyId}/verify") @Transactional @Operation(summary = "정책 금액 검증 표시", description = "확인한 금액을 확정으로 전환") - public ResponseEntity> verify( + public ResponseEntity verify( @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId, @Parameter(description = "금액 근거 출처 URL") @RequestParam(required = false) String sourceUrl) { @@ -46,12 +47,7 @@ public ResponseEntity> verify( policy.setSourceUrl(sourceUrl); policyRepository.save(policy); - Map body = new LinkedHashMap<>(); - body.put("policyId", policyId); - body.put("title", policy.getTitle()); - body.put("verifiedAt", policy.getVerifiedAt()); - body.put("verifiedBy", policy.getVerifiedBy()); - return ResponseEntity.ok(body); + return ResponseEntity.ok(PolicyVerificationResponse.from(policy)); } @DeleteMapping("/{policyId}/verify") @@ -69,22 +65,15 @@ public ResponseEntity unverify(@PathVariable Long policyId) { /** 검증 우선순위 판단용. 미검증 정책이 많은 지역부터 손봐야 한다. */ @GetMapping("/verification-status") @Operation(summary = "지역별 검증 현황", description = "미검증 정책이 많은 지역 순") - public ResponseEntity>> status() { + public ResponseEntity> status() { Map> byRegion = policyRepository.findByIsActiveTrue().stream() .filter(p -> p.getTargetRegion() != null && !p.getTargetRegion().isBlank()) .collect(Collectors.groupingBy(Policy::getTargetRegion)); - List> rows = byRegion.entrySet().stream().map(e -> { - long verified = e.getValue().stream().filter(p -> p.getVerifiedAt() != null).count(); - Map row = new LinkedHashMap<>(); - row.put("region", e.getKey()); - row.put("total", e.getValue().size()); - row.put("verified", verified); - row.put("unverified", e.getValue().size() - verified); - row.put("verifiedRate", e.getValue().isEmpty() ? 0 - : (int) Math.round(100.0 * verified / e.getValue().size())); - return row; - }).sorted(Comparator.comparingInt(r -> (Integer) r.get("verifiedRate"))).toList(); + List rows = byRegion.entrySet().stream() + .map(e -> RegionVerificationStatusResponse.of(e.getKey(), e.getValue())) + .sorted(Comparator.comparingInt(RegionVerificationStatusResponse::verifiedRate)) + .toList(); return ResponseEntity.ok(rows); } diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java index 2c0bfe30..5b227834 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -10,6 +10,10 @@ import com.carecode.core.ops.sync.SyncRunTracker; import com.carecode.domain.facility.service.FacilityVacancyNotifier; import com.carecode.domain.policy.service.PolicyDeadlineNotifier; +import com.carecode.domain.admin.dto.response.DeadlineNotifyResponse; +import com.carecode.domain.admin.dto.response.GeocodingResultResponse; +import com.carecode.domain.admin.dto.response.SyncResultResponse; +import com.carecode.domain.admin.dto.response.VacancyNotifyResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -18,8 +22,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.LinkedHashMap; -import java.util.Map; /** 공공데이터 수동 동기화 API. */ @RestController @@ -41,53 +43,48 @@ public class AdminPublicDataController { @PostMapping("/facilities/sync") @Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신") - public ResponseEntity> syncFacilities() { + public ResponseEntity syncFacilities() { java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); SyncResult result = facilitySyncService.sync(); tracker.recordSyncResult(SyncJob.CHILDCARE_FACILITIES, startedAt, result); - return ResponseEntity.ok(toResponse(result)); + return ResponseEntity.ok(SyncResultResponse.from(result)); } @PostMapping("/kindergartens/sync") @Operation(summary = "전국 유치원 동기화", description = "유치원명·주소 기준으로 갱신") - public ResponseEntity> syncKindergartens() { + public ResponseEntity syncKindergartens() { java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); SyncResult result = kindergartenSyncService.sync(); tracker.recordSyncResult(SyncJob.KINDERGARTENS, startedAt, result); - return ResponseEntity.ok(toResponse(result)); + return ResponseEntity.ok(SyncResultResponse.from(result)); } @PostMapping("/benefits/sync") @Operation(summary = "정부 지원 서비스 동기화", description = "육아 관련 서비스만 정책으로 갱신") - public ResponseEntity> syncBenefits() { + public ResponseEntity syncBenefits() { java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); SyncResult result = benefitSyncService.sync(); tracker.recordSyncResult(SyncJob.GOVERNMENT_BENEFITS, startedAt, result); - return ResponseEntity.ok(toResponse(result)); + return ResponseEntity.ok(SyncResultResponse.from(result)); } @PostMapping("/hospitals/sync") @Operation(summary = "소아청소년과 병원 동기화", description = "요양기호 기준으로 갱신") - public ResponseEntity> syncHospitals() { + public ResponseEntity syncHospitals() { java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); SyncResult result = hospitalSyncService.sync(); tracker.recordSyncResult(SyncJob.PEDIATRIC_HOSPITALS, startedAt, result); - return ResponseEntity.ok(toResponse(result)); + return ResponseEntity.ok(SyncResultResponse.from(result)); } @PostMapping("/facilities/geocode") @Operation(summary = "시설 좌표 보정", description = "좌표 없는 시설의 주소를 좌표로 변환") - public ResponseEntity> geocode() { + public ResponseEntity geocode() { java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); var result = geocodingService.fillMissingCoordinates(); tracker.recordSuccess(SyncJob.FACILITY_GEOCODING, startedAt, result.getResolved(), result.getFailed(), result.getSkippedReason()); - Map body = new LinkedHashMap<>(); - body.put("resolved", result.getResolved()); - body.put("failed", result.getFailed()); - body.put("remaining", result.getRemaining()); - body.put("skippedReason", result.getSkippedReason()); - return ResponseEntity.ok(body); + return ResponseEntity.ok(GeocodingResultResponse.from(result)); } /** @@ -99,36 +96,13 @@ public ResponseEntity> geocode() { */ @PostMapping("/facilities/notify-vacancy") @Operation(summary = "빈자리 알림 실행", description = "대기자가 있는 시설에 새로 난 자리를 알린다") - public ResponseEntity> notifyVacancies() { - var result = vacancyNotifier.notifyNewVacancies(); - Map body = new LinkedHashMap<>(); - body.put("facilitiesChecked", result.getFacilitiesChecked()); - body.put("facilitiesWithVacancy", result.getFacilitiesWithVacancy()); - body.put("notificationsSent", result.getNotificationsSent()); - return ResponseEntity.ok(body); + public ResponseEntity notifyVacancies() { + return ResponseEntity.ok(VacancyNotifyResponse.from(vacancyNotifier.notifyNewVacancies())); } @PostMapping("/policies/notify-deadline") @Operation(summary = "마감 임박 알림 실행", description = "신청 마감이 임박한 지원금을 대상자에게 알린다") - public ResponseEntity> notifyDeadlines() { - var result = policyDeadlineNotifier.notifyUpcomingDeadlines(); - Map body = new LinkedHashMap<>(); - body.put("policiesDueSoon", result.getPoliciesDueSoon()); - body.put("notificationsSent", result.getNotificationsSent()); - return ResponseEntity.ok(body); - } - - private Map toResponse(SyncResult result) { - Map body = new LinkedHashMap<>(); - body.put("provider", result.getProvider()); - body.put("resource", result.getResource()); - body.put("completed", result.isCompleted()); - body.put("created", result.getCreated()); - body.put("updated", result.getUpdated()); - body.put("failed", result.getFailed()); - body.put("skipped", result.getSkipped()); - body.put("pagesProcessed", result.getPagesProcessed()); - body.put("stoppedReason", result.getStoppedReason()); - return body; + public ResponseEntity notifyDeadlines() { + return ResponseEntity.ok(DeadlineNotifyResponse.from(policyDeadlineNotifier.notifyUpcomingDeadlines())); } } diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java b/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java index 25e72d79..b1206b48 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java @@ -1,6 +1,8 @@ package com.carecode.domain.admin.controller; import com.carecode.core.ops.sync.SyncFreshnessService; +import com.carecode.domain.admin.dto.response.ForecastAccuracyMeasureResponse; +import com.carecode.domain.admin.dto.response.SyncStatusResponse; import com.carecode.domain.facility.service.ForecastBacktestService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -10,9 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** * 주기 작업 상태 조회. @@ -32,13 +32,9 @@ public class AdminSyncStatusController { @GetMapping("/status") @Operation(summary = "주기 작업 상태", description = "작업별 마지막 성공 시각, 경과 시간, 신선도 기준 초과 여부") - public ResponseEntity> status() { + public ResponseEntity status() { List jobs = freshnessService.describeAll(); - - Map body = new LinkedHashMap<>(); - body.put("jobs", jobs); - body.put("staleCount", jobs.stream().filter(SyncFreshnessService.JobFreshness::isStale).count()); - return ResponseEntity.ok(body); + return ResponseEntity.ok(SyncStatusResponse.of(jobs)); } /** @@ -49,22 +45,7 @@ public ResponseEntity> status() { */ @org.springframework.web.bind.annotation.PostMapping("/forecast-accuracy/measure") @Operation(summary = "예측 정확도 측정 실행", description = "과거 관측으로 백테스트를 돌려 기간별 정확도를 다시 계산") - public ResponseEntity> measureForecastAccuracy() { - var results = backtestService.runAll(); - - Map body = new LinkedHashMap<>(); - body.put("measured", results.size()); - body.put("results", results.stream().map(r -> { - Map row = new LinkedHashMap<>(); - row.put("horizonMonths", r.getHorizonMonths()); - row.put("samples", r.getSamples()); - row.put("facilities", r.getFacilities()); - row.put("actualRate", r.getActualRate()); - row.put("brierScore", r.getBrierScore()); - row.put("baselineBrierScore", r.getBaselineBrierScore()); - row.put("betterThanBaseline", r.betterThanBaseline()); - return row; - }).toList()); - return ResponseEntity.ok(body); + public ResponseEntity measureForecastAccuracy() { + return ResponseEntity.ok(ForecastAccuracyMeasureResponse.of(backtestService.runAll())); } } diff --git a/src/main/java/com/carecode/domain/admin/dto/response/AdminBookingDashboardResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/AdminBookingDashboardResponse.java new file mode 100644 index 00000000..bc5b1427 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/AdminBookingDashboardResponse.java @@ -0,0 +1,21 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.admin.dto.AdminBookingListResponse; +import com.carecode.domain.admin.dto.AdminBookingStatsResponse; + +import java.util.List; + +/** + * 예약 대시보드 요약. + * + *

통계와 두 목록을 한 번에 주는 이유: 관리자 화면이 세 번 호출해서 조립하면 그 사이에 들어온 + * 예약 때문에 숫자와 목록이 어긋난다. + * + *

{@code recentBookings} 는 최근 10건, {@code todayBookings} 는 오늘 들어온 것 중 5건이다. + * 두 목록은 겹칠 수 있다(오늘 예약은 최근 예약이기도 하다). + */ +public record AdminBookingDashboardResponse( + AdminBookingStatsResponse stats, + List recentBookings, + List todayBookings) { +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/AdminDashboardResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/AdminDashboardResponse.java new file mode 100644 index 00000000..fb93f21f --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/AdminDashboardResponse.java @@ -0,0 +1,28 @@ +package com.carecode.domain.admin.dto.response; + +import java.util.List; + +/** + * 어드민 대시보드 요약. + * + *

가입자 추이를 {@code userTrendLabels}·{@code userTrendData} 두 배열로 주는 이유: 차트 + * 라이브러리가 축과 값을 따로 받기 때문이다. 두 배열의 길이와 순서는 같고(최근 6개월, 과거→현재), + * 값이 없는 달도 0 으로 채워 그래프가 끊기지 않는다. + */ +public record AdminDashboardResponse( + long userCount, + long hospitalCount, + long policyCount, + List recentActivities, + List userTrendLabels, + List userTrendData) { + + /** + * 최근 활동 한 줄. + * + *

{@code time} 은 {@code yyyy-MM-dd HH:mm} 으로 미리 다듬은 문자열이다. 종류가 섞인 + * 목록을 한 줄로 정렬하는 데 쓰므로 모양이 같아야 한다. + */ + public record RecentActivity(String type, String desc, String time) { + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/DeadlineNotifyResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/DeadlineNotifyResponse.java new file mode 100644 index 00000000..16cbdfb5 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/DeadlineNotifyResponse.java @@ -0,0 +1,13 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.policy.service.PolicyDeadlineNotifier.DeadlineNotifyResult; + +/** 마감 임박 알림 실행 결과. 대상 정책 수와 실제 발송 수를 따로 센다. */ +public record DeadlineNotifyResponse( + int policiesDueSoon, + int notificationsSent) { + + public static DeadlineNotifyResponse from(DeadlineNotifyResult result) { + return new DeadlineNotifyResponse(result.getPoliciesDueSoon(), result.getNotificationsSent()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/ForecastAccuracyMeasureResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/ForecastAccuracyMeasureResponse.java new file mode 100644 index 00000000..0a4b0554 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/ForecastAccuracyMeasureResponse.java @@ -0,0 +1,20 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.facility.entity.ForecastAccuracy; + +import java.util.List; + +/** + * 예측 정확도 측정 실행 결과. + * + *

표본이 부족한 기간은 {@code results} 에 들어가지 않는다. 없는 정확도를 만들어내지 않기 + * 위해서다. 그래서 {@code measured} 가 요청한 기간 수보다 작을 수 있다. + */ +public record ForecastAccuracyMeasureResponse(int measured, List results) { + + public static ForecastAccuracyMeasureResponse of(List results) { + return new ForecastAccuracyMeasureResponse( + results.size(), + results.stream().map(ForecastAccuracyRow::from).toList()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/ForecastAccuracyRow.java b/src/main/java/com/carecode/domain/admin/dto/response/ForecastAccuracyRow.java new file mode 100644 index 00000000..56930412 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/ForecastAccuracyRow.java @@ -0,0 +1,31 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.facility.entity.ForecastAccuracy; + +/** + * 예측 기간별 정확도 한 줄. + * + *

{@code brierScore} 는 낮을수록 좋고, 혼자서는 좋은지 알 수 없다. 그래서 "항상 평균 확률을 + * 답한다" 는 기준선({@code baselineBrierScore})을 함께 준다. {@code betterThanBaseline} 이 false 면 + * 예측이 기준선보다 못하다는 뜻이고, 그때는 예측을 보여 주지 않는 쪽이 맞다. + */ +public record ForecastAccuracyRow( + int horizonMonths, + int samples, + int facilities, + double actualRate, + double brierScore, + double baselineBrierScore, + boolean betterThanBaseline) { + + public static ForecastAccuracyRow from(ForecastAccuracy accuracy) { + return new ForecastAccuracyRow( + accuracy.getHorizonMonths(), + accuracy.getSamples(), + accuracy.getFacilities(), + accuracy.getActualRate(), + accuracy.getBrierScore(), + accuracy.getBaselineBrierScore(), + accuracy.betterThanBaseline()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/GeocodingResultResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/GeocodingResultResponse.java new file mode 100644 index 00000000..1f891c51 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/GeocodingResultResponse.java @@ -0,0 +1,21 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.external.geocoding.FacilityGeocodingService.GeocodingResult; + +/** + * 좌표 보정 실행 결과. + * + *

{@code skippedReason} 이 있으면 아예 실행하지 않은 것이다(키 미설정 등). 그때 보정 0 건을 + * "보정할 게 없었다" 로 읽으면 키가 빠진 것을 모른 채 지나간다. + */ +public record GeocodingResultResponse( + int resolved, + int failed, + long remaining, + String skippedReason) { + + public static GeocodingResultResponse from(GeocodingResult result) { + return new GeocodingResultResponse( + result.getResolved(), result.getFailed(), result.getRemaining(), result.getSkippedReason()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/PolicyVerificationResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/PolicyVerificationResponse.java new file mode 100644 index 00000000..1734c704 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/PolicyVerificationResponse.java @@ -0,0 +1,23 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.policy.entity.Policy; + +import java.time.LocalDateTime; + +/** + * 정책 금액 검증 표시 결과. + * + *

누가 언제 확인했는지를 돌려준다. 금액이 "확정" 으로 보이기 시작하는 지점이므로, 근거를 + * 남긴 사람을 화면에서 바로 확인할 수 있어야 한다. + */ +public record PolicyVerificationResponse( + Long policyId, + String title, + LocalDateTime verifiedAt, + String verifiedBy) { + + public static PolicyVerificationResponse from(Policy policy) { + return new PolicyVerificationResponse( + policy.getId(), policy.getTitle(), policy.getVerifiedAt(), policy.getVerifiedBy()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/RegionVerificationStatusResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/RegionVerificationStatusResponse.java new file mode 100644 index 00000000..cd846627 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/RegionVerificationStatusResponse.java @@ -0,0 +1,26 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.policy.entity.Policy; + +import java.util.List; + +/** + * 지역별 정책 금액 검증 현황. 검증률이 낮은 지역부터 손봐야 한다. + * + *

{@code verifiedRate} 는 백분율 정수다. 표본이 작을 때 소수점을 보여 주면 1건 차이가 + * 큰 변화처럼 읽힌다. + */ +public record RegionVerificationStatusResponse( + String region, + int total, + long verified, + long unverified, + int verifiedRate) { + + public static RegionVerificationStatusResponse of(String region, List policies) { + long verified = policies.stream().filter(p -> p.getVerifiedAt() != null).count(); + int rate = policies.isEmpty() ? 0 : (int) Math.round(100.0 * verified / policies.size()); + return new RegionVerificationStatusResponse( + region, policies.size(), verified, policies.size() - verified, rate); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/SyncResultResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/SyncResultResponse.java new file mode 100644 index 00000000..37bea8ff --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/SyncResultResponse.java @@ -0,0 +1,37 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.external.publicdata.sync.SyncResult; + +/** + * 공공데이터 동기화 실행 결과. + * + *

{@code skipped}(필터에 걸려 적재하지 않음)를 {@code failed}(오류) 와 따로 센다. 둘을 합치면 + * "필터가 과도한가" 와 "외부 API 가 깨졌나" 를 구분할 수 없다. + * + *

{@code completed} 가 false 면 {@code stoppedReason} 에 중단 사유가 있다. 중간에 멈춘 실행을 + * 성공으로 읽으면, 실제로는 일부만 들어온 데이터를 전체로 믿게 된다. + */ +public record SyncResultResponse( + String provider, + String resource, + boolean completed, + int created, + int updated, + int failed, + int skipped, + int pagesProcessed, + String stoppedReason) { + + public static SyncResultResponse from(SyncResult result) { + return new SyncResultResponse( + result.getProvider(), + result.getResource(), + result.isCompleted(), + result.getCreated(), + result.getUpdated(), + result.getFailed(), + result.getSkipped(), + result.getPagesProcessed(), + result.getStoppedReason()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/SyncStatusResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/SyncStatusResponse.java new file mode 100644 index 00000000..73843f3a --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/SyncStatusResponse.java @@ -0,0 +1,18 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.core.ops.sync.SyncFreshnessService.JobFreshness; + +import java.util.List; + +/** + * 주기 작업 상태 목록. + * + *

{@code staleCount} 를 함께 주는 이유: 목록이 길어서 화면이 전부 접어 두는데, 하나라도 + * 기준을 넘겼는지는 접힌 상태에서도 보여야 한다. + */ +public record SyncStatusResponse(List jobs, long staleCount) { + + public static SyncStatusResponse of(List jobs) { + return new SyncStatusResponse(jobs, jobs.stream().filter(JobFreshness::isStale).count()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/response/VacancyNotifyResponse.java b/src/main/java/com/carecode/domain/admin/dto/response/VacancyNotifyResponse.java new file mode 100644 index 00000000..543c508a --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/response/VacancyNotifyResponse.java @@ -0,0 +1,20 @@ +package com.carecode.domain.admin.dto.response; + +import com.carecode.domain.facility.service.FacilityVacancyNotifier.VacancyNotifyResult; + +/** + * 빈자리 알림 실행 결과. + * + *

확인한 시설 수까지 주는 이유: 발송 0 건일 때 "대기자가 없어서" 인지 "자리가 안 나서" 인지 + * 구분해야 한다. 발송 수만 보면 둘 다 0 으로 같다. + */ +public record VacancyNotifyResponse( + int facilitiesChecked, + int facilitiesWithVacancy, + int notificationsSent) { + + public static VacancyNotifyResponse from(VacancyNotifyResult result) { + return new VacancyNotifyResponse( + result.getFacilitiesChecked(), result.getFacilitiesWithVacancy(), result.getNotificationsSent()); + } +} diff --git a/src/main/java/com/carecode/domain/community/controller/CommunityController.java b/src/main/java/com/carecode/domain/community/controller/CommunityController.java index c89b1e3f..05b0edfa 100644 --- a/src/main/java/com/carecode/domain/community/controller/CommunityController.java +++ b/src/main/java/com/carecode/domain/community/controller/CommunityController.java @@ -11,6 +11,10 @@ import com.carecode.domain.community.dto.response.CommunityCommentResponse; import com.carecode.domain.community.dto.response.CommunityTagResponse; import com.carecode.domain.community.dto.response.CommunityPageResponse; +import com.carecode.domain.community.dto.response.PostBookmarkCountResponse; +import com.carecode.domain.community.dto.response.PostBookmarkToggleResponse; +import com.carecode.domain.community.dto.response.PostLikeCountResponse; +import com.carecode.domain.community.dto.response.PostLikeToggleResponse; import com.carecode.domain.community.app.CommunityFacade; import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; @@ -21,9 +25,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.HashMap; import java.util.List; -import java.util.Map; import com.carecode.core.handler.ApiSuccess; import java.util.Date; @@ -183,32 +185,26 @@ public ResponseEntity> getAllTags() { @PostMapping("/posts/{postId}/like") @LogExecutionTime @Operation(summary = "게시글 좋아요", description = "게시글에 좋아요를 추가하거나 제거") - public ResponseEntity> toggleLike( + public ResponseEntity toggleLike( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { Long userId = communityFacade.getCurrentAuthenticatedUserId(); boolean isLiked = communityFacade.toggleLike(postId, userId); long likeCount = communityFacade.getLikeCount(postId); - - Map response = new HashMap<>(); - response.put("isLiked", isLiked); - response.put("likeCount", likeCount); - return ResponseEntity.ok(response); + + return ResponseEntity.ok(new PostLikeToggleResponse(isLiked, likeCount)); } // 게시글 북마크 토글 @PostMapping("/posts/{postId}/bookmark") @LogExecutionTime @Operation(summary = "게시글 북마크", description = "게시글을 북마크에 추가하거나 제거") - public ResponseEntity> toggleBookmark( + public ResponseEntity toggleBookmark( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { Long userId = communityFacade.getCurrentAuthenticatedUserId(); boolean isBookmarked = communityFacade.toggleBookmark(postId, userId); long bookmarkCount = communityFacade.getBookmarkCount(postId); - - Map response = new HashMap<>(); - response.put("isBookmarked", isBookmarked); - response.put("bookmarkCount", bookmarkCount); - return ResponseEntity.ok(response); + + return ResponseEntity.ok(new PostBookmarkToggleResponse(isBookmarked, bookmarkCount)); } // 사용자가 좋아요한 게시글 목록 조회 @@ -237,24 +233,18 @@ public ResponseEntity> getBookmarkedPosts( @GetMapping("/posts/{postId}/like-count") @LogExecutionTime @Operation(summary = "게시글 좋아요 수", description = "특정 게시글의 좋아요 수 조회") - public ResponseEntity> getLikeCount( + public ResponseEntity getLikeCount( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { - long count = communityFacade.getLikeCount(postId); - Map response = new HashMap<>(); - response.put("likeCount", count); - return ResponseEntity.ok(response); + return ResponseEntity.ok(new PostLikeCountResponse(communityFacade.getLikeCount(postId))); } // 게시글 북마크 수 조회 @GetMapping("/posts/{postId}/bookmark-count") @LogExecutionTime @Operation(summary = "게시글 북마크 수", description = "특정 게시글의 북마크 수 조회") - public ResponseEntity> getBookmarkCount( + public ResponseEntity getBookmarkCount( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { - long count = communityFacade.getBookmarkCount(postId); - Map response = new HashMap<>(); - response.put("bookmarkCount", count); - return ResponseEntity.ok(response); + return ResponseEntity.ok(new PostBookmarkCountResponse(communityFacade.getBookmarkCount(postId))); } } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/community/dto/response/PostBookmarkCountResponse.java b/src/main/java/com/carecode/domain/community/dto/response/PostBookmarkCountResponse.java new file mode 100644 index 00000000..502b3e57 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/dto/response/PostBookmarkCountResponse.java @@ -0,0 +1,5 @@ +package com.carecode.domain.community.dto.response; + +/** 게시글 북마크 수. */ +public record PostBookmarkCountResponse(long bookmarkCount) { +} diff --git a/src/main/java/com/carecode/domain/community/dto/response/PostBookmarkToggleResponse.java b/src/main/java/com/carecode/domain/community/dto/response/PostBookmarkToggleResponse.java new file mode 100644 index 00000000..a6206e6f --- /dev/null +++ b/src/main/java/com/carecode/domain/community/dto/response/PostBookmarkToggleResponse.java @@ -0,0 +1,13 @@ +package com.carecode.domain.community.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 북마크 토글 결과. + * + *

{@code @JsonProperty} 가 필요한 이유는 {@link PostLikeToggleResponse} 와 같다. + */ +public record PostBookmarkToggleResponse( + @JsonProperty("isBookmarked") boolean isBookmarked, + long bookmarkCount) { +} diff --git a/src/main/java/com/carecode/domain/community/dto/response/PostLikeCountResponse.java b/src/main/java/com/carecode/domain/community/dto/response/PostLikeCountResponse.java new file mode 100644 index 00000000..7697f439 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/dto/response/PostLikeCountResponse.java @@ -0,0 +1,5 @@ +package com.carecode.domain.community.dto.response; + +/** 게시글 좋아요 수. 내가 눌렀는지는 포함하지 않는다(비로그인도 볼 수 있는 값이다). */ +public record PostLikeCountResponse(long likeCount) { +} diff --git a/src/main/java/com/carecode/domain/community/dto/response/PostLikeToggleResponse.java b/src/main/java/com/carecode/domain/community/dto/response/PostLikeToggleResponse.java new file mode 100644 index 00000000..bcf760ad --- /dev/null +++ b/src/main/java/com/carecode/domain/community/dto/response/PostLikeToggleResponse.java @@ -0,0 +1,21 @@ +package com.carecode.domain.community.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 좋아요 토글 결과. + * + *

누른 뒤 상태와 총 개수를 함께 준다. 화면이 자기 쪽에서 +1 하면 다른 기기에서 누른 것과 + * 어긋난다. + * + *

{@code @JsonProperty} 를 붙인 이유. 레코드는 컴포넌트 이름을 그대로 키로 쓰므로 지금은 + * 없어도 {@code isLiked} 로 나간다(확인함). 하지만 Lombok {@code @Getter} 를 쓰는 클래스로 + * 바꾸면 접근자가 {@code isLiked()} 가 되고 Jackson 이 {@code is} 를 떼어 키가 {@code liked} 로 + * 바뀐다 — 이 프로젝트에서 이미 {@code isRead}·{@code isAnonymous} 로 겪은 일이다. 프런트는 + * {@code isLiked} 를 {@code z.boolean()} 으로 필수로 읽으므로 그 순간 토글이 깨진다. + * 키를 적어 두면 어느 쪽으로 바꿔도 같다. + */ +public record PostLikeToggleResponse( + @JsonProperty("isLiked") boolean isLiked, + long likeCount) { +} diff --git a/src/main/java/com/carecode/domain/facility/controller/CareFacilityApiController.java b/src/main/java/com/carecode/domain/facility/controller/CareFacilityApiController.java deleted file mode 100644 index 11e52e9c..00000000 --- a/src/main/java/com/carecode/domain/facility/controller/CareFacilityApiController.java +++ /dev/null @@ -1,373 +0,0 @@ -package com.carecode.domain.facility.controller; - -import com.carecode.external.publicdata.CareFacilityApiService; -import com.carecode.domain.facility.dto.response.CareFacilityInfo; -import com.carecode.domain.facility.dto.response.CareFacilityStatsResponse; -import com.carecode.domain.facility.entity.FacilityType; -import com.carecode.domain.facility.service.CareFacilityService; -import com.carecode.core.util.PageRequestUtil; -import com.carecode.domain.facility.entity.CareFacility; -import com.carecode.domain.facility.repository.CareFacilityRepository; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; -import java.util.List; -import java.util.Optional; - -/** 돌봄시설 공공데이터 API 컨트롤러 */ -@Slf4j -@RestController -@RequestMapping("/api/public/care-facilities") -@RequiredArgsConstructor -@Tag(name = "돌봄시설 공공데이터", description = "공공데이터 포털의 돌봄시설 정보 API") -public class CareFacilityApiController { - - private final CareFacilityApiService careFacilityApiService; - private final CareFacilityService careFacilityService; - private final CareFacilityRepository careFacilityRepository; - private FacilityType facilityType; - - // 전체 보육시설 정보 동기화 (모든 데이터) - @PostMapping("/sync-all") - @Operation(summary = "전체 보육시설 정보 동기화", description = "공공데이터에서 모든 보육시설 정보를 조회하고 DB에 저장") - public ResponseEntity> syncAllChildcareFacilities( - @Parameter(description = "시도명", example = "서울특별시") @RequestParam(defaultValue = "서울특별시") String sido, - @Parameter(description = "시군구명", example = "강남구") @RequestParam(defaultValue = "강남구") String sigungu, - @Parameter(description = "한 페이지 결과 수", example = "100") @RequestParam(defaultValue = "100") int numOfRows) { - - try { - int totalSaved = 0; - int totalUpdated = 0; - int totalError = 0; - int pageNo = 1; - boolean hasMoreData = true; - - while (hasMoreData) { - try { - log.info("페이지 {} 처리 중...", pageNo); - - // 1. 공공데이터 API에서 데이터 조회 - Map publicData = careFacilityApiService.getChildcareFacilities(sido, sigungu, pageNo, numOfRows); - - // 2. 결과 확인 - Boolean success = (Boolean) publicData.get("success"); - if (success == null || !success) { - log.warn("페이지 {}에서 데이터 조회 실패: {}", pageNo, publicData.get("message")); - break; - } - - @SuppressWarnings("unchecked") - List> facilities = (List>) publicData.get("facilities"); - - if (facilities == null || facilities.isEmpty()) { - log.info("페이지 {}에 더 이상 데이터가 없습니다.", pageNo); - hasMoreData = false; - break; - } - - log.info("페이지 {}에서 {}개 시설 발견", pageNo, facilities.size()); - - // 3. DB에 저장 - int savedCount = 0; - int updatedCount = 0; - int errorCount = 0; - - for (Map facilityData : facilities) { - try { - String facilityId = (String) facilityData.get("facilityId"); - - // 기존 시설이 있는지 확인 - Optional existingFacilityOpt = careFacilityRepository.findByFacilityCode(facilityId); - - if (existingFacilityOpt.isPresent()) { - // 기존 시설 업데이트 - CareFacility existingFacility = existingFacilityOpt.get(); - updateCareFacilityFromPublicData(existingFacility, facilityData); - updatedCount++; - } else { - // 새 시설 생성 - CareFacility newFacility = createCareFacilityFromPublicData(facilityData); - careFacilityRepository.save(newFacility); - savedCount++; - } - - } catch (Exception e) { - errorCount++; - log.error("시설 데이터 저장 중 오류: {}", e.getMessage()); - } - } - - totalSaved += savedCount; - totalUpdated += updatedCount; - totalError += errorCount; - - log.info("페이지 {} 완료: 신규={}, 업데이트={}, 오류={}", pageNo, savedCount, updatedCount, errorCount); - - // 다음 페이지로 - pageNo++; - - // 너무 많은 페이지를 처리하지 않도록 제한 (안전장치) - if (pageNo > 50) { - log.warn("페이지 제한에 도달했습니다. (최대 50페이지)"); - break; - } - - } catch (Exception e) { - log.error("페이지 {} 처리 중 오류: {}", pageNo, e.getMessage()); - break; - } - } - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "전체 보육시설 정보 동기화 완료", - "totalPages", pageNo - 1, - "totalSaved", totalSaved, - "totalUpdated", totalUpdated, - "totalError", totalError - )); - - } catch (Exception e) { - log.error("전체 보육시설 정보 동기화 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "전체 보육시설 정보 동기화 실패: " + e.getMessage() - )); - } - } - - // Swagger UI용 간단한 보육시설 동기화 (GET 방식) - @GetMapping("/swagger/sync") - @Operation(summary = "Swagger용 보육시설 동기화", description = "Swagger UI에서 쉽게 테스트할 수 있는 GET 방식 동기화") - public ResponseEntity> swaggerSync() { - try { - log.info("Swagger용 보육시설 동기화 요청"); - - // 기본 파라미터로 전체 동기화 실행 - return syncAllChildcareFacilities("서울특별시", "강남구", 1000); - - } catch (Exception e) { - log.error("Swagger용 보육시설 동기화 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "Swagger용 보육시설 동기화 실패: " + e.getMessage() - )); - } - } - - // DB 저장된 보육시설 목록 조회 - @GetMapping("/swagger/db-facilities") - @Operation(summary = "DB 저장된 보육시설 목록") - public ResponseEntity> swaggerGetDbFacilities( - @Parameter(description = "페이지 번호", example = "0") @RequestParam(defaultValue = "0") int page, - @Parameter(description = "한 페이지 결과 수", example = "10") @RequestParam(defaultValue = "10") int size) { - - try { - log.info("DB 저장된 보육시설 목록 조회 요청: page={}, size={}", page, size); - - // 전체를 읽어 subList 로 자르면 테이블이 커질수록 그대로 부하가 되고, start 가 목록 크기를 넘으면 IndexOutOfBoundsException 이 난다 - int safePage = PageRequestUtil.normalizePage(page); - int safeSize = PageRequestUtil.normalizeSize(size); - List pagedFacilities = careFacilityService.getAllCareFacilities(safePage, safeSize); - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "DB 저장된 보육시설 목록 조회 성공", - "totalCount", careFacilityService.countCareFacilities(), - "currentPage", safePage, - "pageSize", safeSize, - "data", pagedFacilities - )); - - } catch (Exception e) { - log.error("DB 저장된 보육시설 목록 조회 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "DB 저장된 보육시설 목록 조회 실패: " + e.getMessage() - )); - } - } - - // 보육시설 통계 - @GetMapping("/swagger/stats") - @Operation(summary = "보육시설 통계", description = "DB에 저장된 보육시설 통계 정보 조회") - public ResponseEntity> swaggerGetStats() { - try { - log.info("보육시설 통계 조회 요청"); - - // CareFacilityService를 통해 통계 조회 - CareFacilityStatsResponse stats = careFacilityService.getFacilityStats(); - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "보육시설 통계 조회 성공", - "data", stats - )); - - } catch (Exception e) { - log.error("보육시설 통계 조회 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "보육시설 통계 조회 실패: " + e.getMessage() - )); - } - } - - // 공공데이터로부터 새로운 CareFacility 엔티티 생성 - private CareFacility createCareFacilityFromPublicData(Map facilityData) { - try { - return CareFacility.builder() - .facilityCode((String) facilityData.get("facilityId")) - .name((String) facilityData.get("facilityName")) - .facilityType(mapServiceTypeToFacilityType((String) facilityData.get("serviceType"))) - .city("서울특별시") // 서울시 API이므로 고정 - .district((String) facilityData.get("district")) - .address((String) facilityData.get("address")) - .latitude(parseDouble(facilityData.get("latitude"))) - .longitude(parseDouble(facilityData.get("longitude"))) - .website((String) facilityData.get("websiteUrl")) - .ageRange((String) facilityData.get("ageGroup")) - .operatingHours(formatOperatingHours(facilityData)) - .tuitionFee(parseInteger(facilityData.get("rentFee"))) - .isActive(true) - .isPublic(true) // 공공데이터는 주로 공립 시설 - .subsidyAvailable((Boolean) facilityData.get("isFree")) - .description(generateDescription(facilityData)) - .build(); - - } catch (Exception e) { - log.error("CareFacility 엔티티 생성 중 오류: {}", e.getMessage(), e); - throw new RuntimeException("CareFacility 엔티티 생성 실패: " + e.getMessage(), e); - } - } - - // 기존 CareFacility 엔티티를 공공데이터로 업데이트 - private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map facilityData) { - try { - existingFacility.setName((String) facilityData.get("facilityName")); - existingFacility.setFacilityType(mapServiceTypeToFacilityType((String) facilityData.get("serviceType"))); - existingFacility.setDistrict((String) facilityData.get("district")); - existingFacility.setAddress((String) facilityData.get("address")); - existingFacility.setLatitude(parseDouble(facilityData.get("latitude"))); - existingFacility.setLongitude(parseDouble(facilityData.get("longitude"))); - existingFacility.setWebsite((String) facilityData.get("websiteUrl")); - existingFacility.setAgeRange((String) facilityData.get("ageGroup")); - existingFacility.setOperatingHours(formatOperatingHours(facilityData)); - existingFacility.setTuitionFee(parseInteger(facilityData.get("rentFee"))); - existingFacility.setSubsidyAvailable((Boolean) facilityData.get("isFree")); - existingFacility.setDescription(generateDescription(facilityData)); - - careFacilityRepository.save(existingFacility); - - } catch (Exception e) { - log.error("CareFacility 엔티티 업데이트 중 오류: {}", e.getMessage(), e); - throw new RuntimeException("CareFacility 엔티티 업데이트 실패: " + e.getMessage(), e); - } - } - - // 서비스 타입을 FacilityType으로 매핑 - private FacilityType mapServiceTypeToFacilityType(String serviceType) { - if (serviceType == null) { - return FacilityType.OTHER; - } - - switch (serviceType) { - case "공동육아나눔터": - return FacilityType.PLAYGROUP; - case "어린이집": - return FacilityType.DAYCARE; - case "유치원": - return FacilityType.KINDERGARTEN; - case "방과후교실": - return FacilityType.NURSERY; - default: - return FacilityType.OTHER; - } - } - - // 운영시간 정보 포맷팅 - private String formatOperatingHours(Map facilityData) { - StringBuilder hours = new StringBuilder(); - - String weekdayStart = (String) facilityData.get("weekdayStartTime"); - String weekdayEnd = (String) facilityData.get("weekdayEndTime"); - String operationType = (String) facilityData.get("operationType"); - Boolean hasSaturday = (Boolean) facilityData.get("hasSaturdayOperation"); - String saturdayStart = (String) facilityData.get("saturdayStartTime"); - String saturdayEnd = (String) facilityData.get("saturdayEndTime"); - - if (weekdayStart != null && weekdayEnd != null) { - hours.append("평일: ").append(weekdayStart).append("~").append(weekdayEnd); - } - - if (operationType != null && !operationType.isEmpty()) { - hours.append(" (").append(operationType).append(")"); - } - - if (hasSaturday != null && hasSaturday && saturdayStart != null && saturdayEnd != null) { - hours.append(", 토요일: ").append(saturdayStart).append("~").append(saturdayEnd); - } - - return hours.toString(); - } - - // 시설 설명 생성 - private String generateDescription(Map facilityData) { - StringBuilder description = new StringBuilder(); - - String serviceType = (String) facilityData.get("serviceType"); - String ageGroup = (String) facilityData.get("ageGroup"); - String district = (String) facilityData.get("district"); - Boolean isFree = (Boolean) facilityData.get("isFree"); - - if (serviceType != null) { - description.append(serviceType); - } - - if (ageGroup != null) { - description.append(" - ").append(ageGroup); - } - - if (district != null) { - description.append(" (").append(district).append(")"); - } - - if (isFree != null && isFree) { - description.append(" - 무료 이용 가능"); - } - - return description.toString(); - } - - // Double 파싱 헬퍼 메서드 - private Double parseDouble(Object value) { - if (value == null || value.toString().trim().isEmpty()) { - return null; - } - try { - return Double.parseDouble(value.toString()); - } catch (NumberFormatException e) { - log.warn("Double 파싱 실패: {}", value); - return null; - } - } - - // Integer 파싱 헬퍼 메서드 - private Integer parseInteger(Object value) { - if (value == null || value.toString().trim().isEmpty()) { - return null; - } - try { - return Integer.parseInt(value.toString()); - } catch (NumberFormatException e) { - log.warn("Integer 파싱 실패: {}", value); - return null; - } - } -} diff --git a/src/main/java/com/carecode/domain/facility/controller/FacilityWaitlistController.java b/src/main/java/com/carecode/domain/facility/controller/FacilityWaitlistController.java index 1630a169..32106f30 100644 --- a/src/main/java/com/carecode/domain/facility/controller/FacilityWaitlistController.java +++ b/src/main/java/com/carecode/domain/facility/controller/FacilityWaitlistController.java @@ -2,8 +2,9 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.domain.facility.dto.request.WaitlistRequest; +import com.carecode.domain.facility.dto.response.WaitlistEntryResponse; +import com.carecode.domain.facility.dto.response.WaitlistRegisterResponse; import com.carecode.domain.facility.dto.response.WaitlistStatsResponse; -import com.carecode.domain.facility.entity.FacilityWaitlist; import com.carecode.domain.facility.service.FacilityWaitlistService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -15,9 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** 어린이집 대기 기록. 실제 대기 기간은 공공데이터에 없어 사용자에게서만 얻는다. */ @RestController @@ -31,14 +30,12 @@ public class FacilityWaitlistController { @PostMapping("/{facilityId}/waitlist") @LogExecutionTime @Operation(summary = "대기 신청 기록", description = "대기 순번과 신청일을 남깁니다") - public ResponseEntity> register( + public ResponseEntity register( @Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId, @Valid @RequestBody WaitlistRequest request) { Long id = waitlistService.register(facilityId, request); - Map body = new LinkedHashMap<>(); - body.put("waitlistId", id); - return ResponseEntity.ok(body); + return ResponseEntity.ok(new WaitlistRegisterResponse(id)); } @PatchMapping("/waitlist/{waitlistId}") @@ -57,9 +54,9 @@ public ResponseEntity resolve( @GetMapping("/waitlist/me") @LogExecutionTime @Operation(summary = "내 대기 목록", description = "등록한 대기 기록 조회") - public ResponseEntity>> myWaitlists() { - List> rows = waitlistService.getMyWaitlists().stream() - .map(this::toSummary) + public ResponseEntity> myWaitlists() { + List rows = waitlistService.getMyWaitlists().stream() + .map(WaitlistEntryResponse::from) .toList(); return ResponseEntity.ok(rows); } @@ -70,16 +67,4 @@ public ResponseEntity>> myWaitlists() { public ResponseEntity stats(@PathVariable Long facilityId) { return ResponseEntity.ok(waitlistService.getStats(facilityId)); } - - private Map toSummary(FacilityWaitlist entry) { - Map row = new LinkedHashMap<>(); - row.put("waitlistId", entry.getId()); - row.put("facilityId", entry.getFacilityId()); - row.put("waitNumber", entry.getWaitNumber()); - row.put("appliedAt", entry.getAppliedAt()); - row.put("status", entry.getStatus().name()); - row.put("statusName", entry.getStatus().getDisplayName()); - row.put("waitedDays", entry.waitedDays()); - return row; - } } diff --git a/src/main/java/com/carecode/domain/facility/dto/response/WaitlistEntryResponse.java b/src/main/java/com/carecode/domain/facility/dto/response/WaitlistEntryResponse.java new file mode 100644 index 00000000..5c098a7e --- /dev/null +++ b/src/main/java/com/carecode/domain/facility/dto/response/WaitlistEntryResponse.java @@ -0,0 +1,32 @@ +package com.carecode.domain.facility.dto.response; + +import com.carecode.domain.facility.entity.FacilityWaitlist; + +import java.time.LocalDate; + +/** + * 내가 등록한 대기 기록 한 줄. + * + *

{@code status} 와 {@code statusName} 을 함께 준다. 화면이 코드값을 직접 번역하면 + * 서버가 상태를 추가할 때 화면에 빈칸이 생긴다. + */ +public record WaitlistEntryResponse( + Long waitlistId, + Long facilityId, + Integer waitNumber, + LocalDate appliedAt, + String status, + String statusName, + long waitedDays) { + + public static WaitlistEntryResponse from(FacilityWaitlist entry) { + return new WaitlistEntryResponse( + entry.getId(), + entry.getFacilityId(), + entry.getWaitNumber(), + entry.getAppliedAt(), + entry.getStatus().name(), + entry.getStatus().getDisplayName(), + entry.waitedDays()); + } +} diff --git a/src/main/java/com/carecode/domain/facility/dto/response/WaitlistRegisterResponse.java b/src/main/java/com/carecode/domain/facility/dto/response/WaitlistRegisterResponse.java new file mode 100644 index 00000000..6557256e --- /dev/null +++ b/src/main/java/com/carecode/domain/facility/dto/response/WaitlistRegisterResponse.java @@ -0,0 +1,9 @@ +package com.carecode.domain.facility.dto.response; + +/** + * 대기 등록 결과. + * + *

등록 직후 프런트가 "방금 등록한 기록" 을 가리킬 수 있어야 하므로 id 를 돌려준다. + */ +public record WaitlistRegisterResponse(Long waitlistId) { +} diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index cc819e6a..81cc53d0 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -3,6 +3,7 @@ import com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest; import com.carecode.domain.health.dto.request.HealthRecordAttachmentRequest; import com.carecode.domain.health.dto.request.HealthUpdateHealthRecordRequest; +import com.carecode.domain.health.dto.response.HealthRecommendationResponse; import com.carecode.domain.health.dto.response.HealthRecordResponse; import com.carecode.domain.health.dto.response.HealthRecordAttachmentResponse; import com.carecode.domain.health.dto.response.VaccineScheduleResponse; @@ -135,7 +136,7 @@ public Map getHealthGoals(String userId, Long actorUserId) { return healthService.getHealthGoals(userId, actorUserId); } - public Map getIntegratedRecommendations(String userId, Long actorUserId) { + public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { return healthService.getIntegratedRecommendations(userId, actorUserId); } diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index bbae0bbb..a31dc069 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -10,6 +10,7 @@ import com.carecode.domain.health.dto.request.HealthUpdateHealthRecordRequest; import com.carecode.domain.health.dto.request.HealthCreateHospitalReviewRequest; import com.carecode.domain.health.dto.request.HealthUpdateHospitalReviewRequest; +import com.carecode.domain.health.dto.response.HealthRecommendationResponse; import com.carecode.domain.health.dto.response.*; import com.carecode.domain.health.app.HealthFacade; import com.carecode.domain.health.entity.HealthRecord; @@ -185,7 +186,7 @@ public ResponseEntity> getHealthAlerts( @GetMapping("/recommendations") @LogExecutionTime @Operation(summary = "연계 추천 조회", description = "아동 연령 기반 정책/시설 연계 추천 제공") - public ResponseEntity> getIntegratedRecommendations() { + public ResponseEntity getIntegratedRecommendations() { return ResponseEntity.ok(healthFacade.getIntegratedRecommendations(getAuthenticatedUserCode(), getAuthenticatedUserPk())); } diff --git a/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java new file mode 100644 index 00000000..71b7c350 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java @@ -0,0 +1,20 @@ +package com.carecode.domain.health.dto.response; + +import java.util.List; + +/** + * 아이 연령 기준 연계 추천. + * + *

추천 정책·시설은 **이름 문자열만** 담는다. id 가 없어 상세로 바로 보낼 수 없고, 화면에서는 + * 검색 지름길로 쓴다. (id 를 주려면 정책·시설 쪽 추천 API 를 쓰는 게 맞다.) + * + *

등록된 자녀가 없으면 {@code childAge} 가 0 이다. 그 경우 추천은 0세 기준이 되므로, + * 화면은 "자녀를 등록하세요" 를 먼저 보여 주는 쪽이 정확하다. + */ +public record HealthRecommendationResponse( + String userId, + int childAge, + List recommendedPolicies, + List recommendedFacilities, + String nudgeMessage) { +} 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 ebc0dbe8..668603b7 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -14,6 +14,7 @@ import com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest; import com.carecode.domain.health.dto.request.HealthRecordAttachmentRequest; import com.carecode.domain.health.dto.request.HealthUpdateHealthRecordRequest; +import com.carecode.domain.health.dto.response.HealthRecommendationResponse; import com.carecode.domain.health.dto.response.HealthRecordAttachmentResponse; import com.carecode.domain.health.dto.response.HealthRecordResponse; import com.carecode.domain.health.dto.response.VaccineScheduleResponse; @@ -508,7 +509,7 @@ public List getHealthAlerts(String userId, Long actorUserId } @LogExecutionTime - public Map getIntegratedRecommendations(String userId, Long actorUserId) { + public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { assertUserIdBelongsToActor(userId, actorUserId); User user = findUserByIdOrUserId(userId); Integer childAge = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()).stream() @@ -527,13 +528,12 @@ public Map getIntegratedRecommendations(String userId, Long acto .map(CareFacility::getName) .collect(Collectors.toList()); - Map recommendations = new HashMap<>(); - recommendations.put("userId", user.getUserId()); - recommendations.put("childAge", childAge); - recommendations.put("recommendedPolicies", recommendedPolicies); - recommendations.put("recommendedFacilities", recommendedFacilities); - recommendations.put("nudgeMessage", "아이 연령에 맞는 정책/시설을 확인해보세요."); - return recommendations; + return new HealthRecommendationResponse( + user.getUserId(), + childAge, + recommendedPolicies, + recommendedFacilities, + "아이 연령에 맞는 정책/시설을 확인해보세요."); } // 건강 목표 조회 diff --git a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java index c70d9028..e2994197 100644 --- a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java +++ b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java @@ -134,15 +134,6 @@ public List getUnreadNotifications(String userId) { return notificationService.getUnreadNotifications(userId); } - @Transactional(readOnly = true) - public Map getNotificationSettings(String userId) { return notificationService.getNotificationSettings(userId); } - - @Transactional - public Map updateNotificationSettings(String userId, Map settings) { return notificationService.updateNotificationSettings(userId, settings); } - - @Transactional(readOnly = true) - public Map getNotificationStatistics(String userId) { return notificationService.getNotificationStatistics(userId); } - @Transactional(readOnly = true) public List getUserPreferences(String userId) { return preferenceService.getUserPreferences(userId); } diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java index 535ff996..1cb2bbec 100644 --- a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java @@ -151,39 +151,14 @@ public ResponseEntity> getUnreadNotifications() { return ResponseEntity.ok(notifications); } - // 알림 설정 조회 - @GetMapping("/settings/{userId}") - @LogExecutionTime - @Operation(summary = "알림 설정 조회") - public ResponseEntity> getNotificationSettings(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - requireNotificationUserIdMatchesCurrent(userId); - Map settings = notificationFacade.getNotificationSettings(getAuthenticatedUserCode()); - - return ResponseEntity.ok(settings); - } - - // 알림 설정 업데이트 - @PutMapping("/settings/{userId}") - @LogExecutionTime - @Operation(summary = "알림 설정 업데이트", description = "사용자의 알림 설정을 업데이트") - public ResponseEntity> updateNotificationSettings(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, - @Parameter(description = "알림 설정", required = true) @RequestBody Map settings) { - requireNotificationUserIdMatchesCurrent(userId); - Map updatedSettings = notificationFacade.updateNotificationSettings(getAuthenticatedUserCode(), settings); - - return ResponseEntity.ok(updatedSettings); - } - - // 알림 통계 조회 - @GetMapping("/statistics/{userId}") - @LogExecutionTime - @Operation(summary = "알림 통계 조회", description = "사용자의 알림 관련 통계 조회") - public ResponseEntity> getNotificationStatistics(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - requireNotificationUserIdMatchesCurrent(userId); - Map statistics = notificationFacade.getNotificationStatistics(getAuthenticatedUserCode()); - - return ResponseEntity.ok(statistics); - } + /* + * 알림 설정 조회·수정과 통계는 아래 /preferences 와 /stats 로만 받는다. + * + * 한때 같은 일을 하는 /settings/{userId}(GET·PUT) 와 /statistics/{userId} 가 있었다. + * 둘은 설정을 저장하지 않고 받은 값을 그대로 돌려주거나 고정값(이메일 켬, 조용한 시간 22:00, + * 우선순위 분포 HIGH=5 …)을 돌려줬다. 응답이 그럴듯해서 "설정이 저장된다" 고 읽히는 쪽이 + * 설정이 없는 것보다 위험하므로 삭제했다. 실제 저장·조회는 /preferences 가 한다. + */ // 알림 설정 목록 조회 @GetMapping("/preferences") diff --git a/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java b/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java index 1bd60203..de8abafb 100644 --- a/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java @@ -37,9 +37,6 @@ List findByDateRange(@Param("userId") Long userId, // 사용자별 알림 개수 조회 long countByUserId(Long userId); - // 읽지 않은 알림 개수 조회 - long countByUserIdAndIsReadFalse(Long userId); - // 알림 타입별 개수 조회 long countByUserIdAndNotificationType(Long userId, Notification.NotificationType notificationType); diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationService.java index a44beb12..26ccb7fc 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationService.java @@ -191,73 +191,6 @@ public List getUnreadNotifications(String userId) { .collect(Collectors.toList()); } - // 알림 설정 조회 - @LogExecutionTime - public Map getNotificationSettings(String userId) { - User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - - Map settings = new HashMap<>(); - settings.put("userId", userId); - settings.put("emailEnabled", true); - settings.put("pushEnabled", true); - settings.put("smsEnabled", false); - settings.put("enabledTypes", strategyFactory.getSupportedNotificationTypes()); - settings.put("quietHoursStart", "22:00"); - settings.put("quietHoursEnd", "08:00"); - - return settings; - } - - // 알림 설정 업데이트 - @Transactional - public Map updateNotificationSettings(String userId, Map settings) { - log.info("알림 설정 업데이트 - 사용자 ID: {}", userId); - - try { - User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - - // 실제로는 NotificationSettings 엔티티를 업데이트 - Map updatedSettings = new HashMap<>(settings); - updatedSettings.put("userId", userId); - updatedSettings.put("updatedAt", LocalDateTime.now().toString()); - - return updatedSettings; - } catch (Exception e) { - log.error("알림 설정 업데이트 실패: {}", e.getMessage()); - throw new CareServiceException("알림 설정 업데이트 중 오류가 발생했습니다.", e); - } - } - - // 알림 통계 조회 - @LogExecutionTime - public Map getNotificationStatistics(String userId) { - log.info("알림 통계 조회 - 사용자 ID: {}", userId); - - try { - User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - - long totalNotifications = notificationRepository.countByUserId(user.getId()); - long unreadCount = notificationRepository.countByUserIdAndIsReadFalse(user.getId()); - long readCount = totalNotifications - unreadCount; - - Map statistics = new HashMap<>(); - statistics.put("userId", userId); - statistics.put("totalNotifications", totalNotifications); - statistics.put("unreadCount", unreadCount); - statistics.put("readCount", readCount); - statistics.put("typeDistribution", calculateTypeDistribution(user.getId())); - statistics.put("priorityDistribution", Map.of("HIGH", 5, "NORMAL", 15, "LOW", 5)); - statistics.put("dailyNotificationCount", Map.of("2024-01-15", 3, "2024-01-16", 2)); - - return statistics; - } catch (Exception e) { - log.error("알림 통계 조회 실패: {}", e.getMessage()); - throw new CareServiceException("알림 통계 조회 중 오류가 발생했습니다.", e); - } - } // 기존 메서드들 (호환성을 위해 유지) diff --git a/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java b/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java index 9e787492..b5f73eb7 100644 --- a/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java @@ -7,6 +7,7 @@ import com.carecode.core.security.RefreshTokenCookieFactory; import com.carecode.core.util.KakaoUtil; import com.carecode.domain.user.dto.request.KakaoRegistrationRequest; +import com.carecode.domain.user.dto.response.KakaoLoginUrlResponse; import com.carecode.domain.user.dto.response.TokenDto; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.service.AuthService; @@ -21,7 +22,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; /** 카카오 로그인 관련 통합 컨트롤러 */ @RestController @@ -70,13 +70,8 @@ public ResponseEntity completeRegistration( @GetMapping("/login-url") @LogExecutionTime @Operation(summary = "카카오 로그인 URL 생성", description = "카카오 OAuth 로그인을 위한 URL 생성") - public ResponseEntity> getKakaoLoginUrl() { + public ResponseEntity getKakaoLoginUrl() { log.debug("카카오 로그인 URL 생성 요청"); - String kakaoLoginUrl = kakaoUtil.buildAuthorizationUrl(); - return ResponseEntity.ok(Map.of( - "success", true, - "loginUrl", kakaoLoginUrl, - "message", "카카오 로그인 URL이 생성되었습니다." - )); + return ResponseEntity.ok(KakaoLoginUrlResponse.of(kakaoUtil.buildAuthorizationUrl())); } } diff --git a/src/main/java/com/carecode/domain/user/controller/PrivacyController.java b/src/main/java/com/carecode/domain/user/controller/PrivacyController.java index e9369cb8..b11b5bb5 100644 --- a/src/main/java/com/carecode/domain/user/controller/PrivacyController.java +++ b/src/main/java/com/carecode/domain/user/controller/PrivacyController.java @@ -4,6 +4,7 @@ import com.carecode.core.util.ClientIpResolver; import com.carecode.domain.user.dto.request.ConsentUpdateRequest; import com.carecode.domain.user.dto.response.ConsentStatusResponse; +import com.carecode.domain.user.dto.response.MyDataExportResponse; import com.carecode.domain.user.service.PrivacyService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,7 +15,6 @@ import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Map; /** 개인정보 관련 API: 동의 관리, 내 데이터 열람, 회원 탈퇴. */ @RestController @@ -55,7 +55,7 @@ public ResponseEntity> getConsent @LogExecutionTime @Operation(summary = "내 데이터 내려받기", description = "개인정보 열람권 행사를 위한 데이터 export") - public ResponseEntity> exportMyData() { + public ResponseEntity exportMyData() { return ResponseEntity.ok(privacyService.exportMyData()); } diff --git a/src/main/java/com/carecode/domain/user/dto/response/KakaoLoginUrlResponse.java b/src/main/java/com/carecode/domain/user/dto/response/KakaoLoginUrlResponse.java new file mode 100644 index 00000000..88ed4ef3 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/dto/response/KakaoLoginUrlResponse.java @@ -0,0 +1,14 @@ +package com.carecode.domain.user.dto.response; + +/** + * 카카오 인가 페이지 주소. + * + *

{@code redirect_uri} 는 서버 설정({@code KAKAO_REDIRECT_URI})을 쓰므로 프런트가 만들지 않는다. + * 주소를 프런트가 조립하면 설정과 어긋날 때 카카오 쪽에서 막힌다. + */ +public record KakaoLoginUrlResponse(boolean success, String loginUrl, String message) { + + public static KakaoLoginUrlResponse of(String loginUrl) { + return new KakaoLoginUrlResponse(true, loginUrl, "카카오 로그인 URL이 생성되었습니다."); + } +} diff --git a/src/main/java/com/carecode/domain/user/dto/response/MyDataExportResponse.java b/src/main/java/com/carecode/domain/user/dto/response/MyDataExportResponse.java new file mode 100644 index 00000000..6b3c6733 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/dto/response/MyDataExportResponse.java @@ -0,0 +1,70 @@ +package com.carecode.domain.user.dto.response; + +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 내 데이터 전체 내려받기(열람권 행사) 응답. + * + *

비밀번호·토큰 등 인증 정보는 담지 않는다. 열람권은 "내 정보를 본다" 는 권리이지 인증 + * 수단을 받아 가는 것이 아니고, 내려받은 파일이 그대로 유출되면 계정이 넘어간다. + * + *

건강 기록과 게시글은 **건수만** 담는다. 본문까지 넣으면 응답이 수 MB 가 되고, 개별 내용은 + * 각 도메인 API 로 이미 열람할 수 있다. 이 응답의 목적은 "무엇이 얼마나 보관돼 있는지" 를 + * 한 번에 보여 주는 것이다. + */ +public record MyDataExportResponse( + LocalDateTime exportedAt, + Profile profile, + List children, + int healthRecordCount, + long postCount, + List consentHistory) { + + /** 가입자 본인 정보. */ + public record Profile( + String userId, + String email, + String name, + String phoneNumber, + LocalDate birthDate, + String address, + String role, + LocalDateTime createdAt, + LocalDateTime lastLoginAt) { + + public static Profile from(User user) { + return new Profile( + user.getUserId(), + user.getEmail(), + user.getName(), + user.getPhoneNumber(), + user.getBirthDate(), + user.getAddress(), + user.getRole() != null ? user.getRole().name() : null, + user.getCreatedAt(), + user.getLastLoginAt()); + } + } + + /** + * 자녀 정보. + * + *

{@code birthDate}·{@code gender} 는 문자열이다. 기존 응답이 {@code String.valueOf()} 로 + * 만든 값을 내보내고 있었고(미등록이면 {@code "null"} 문자열), 프런트가 그대로 표시하므로 + * 타입만 바꾸면 화면이 달라진다. 모양은 그대로 두고 {@code null} 은 {@code null} 로 보낸다. + */ + public record ChildData(String name, String birthDate, String gender) { + + public static ChildData from(Child child) { + return new ChildData( + child.getName(), + child.getBirthDate() != null ? child.getBirthDate().toString() : null, + child.getGender()); + } + } +} diff --git a/src/main/java/com/carecode/domain/user/service/PrivacyService.java b/src/main/java/com/carecode/domain/user/service/PrivacyService.java index 1d9a6e97..7f38e6fe 100644 --- a/src/main/java/com/carecode/domain/user/service/PrivacyService.java +++ b/src/main/java/com/carecode/domain/user/service/PrivacyService.java @@ -5,6 +5,7 @@ import com.carecode.domain.health.repository.HealthRecordRepository; import com.carecode.domain.user.dto.request.ConsentUpdateRequest; import com.carecode.domain.user.dto.response.ConsentStatusResponse; +import com.carecode.domain.user.dto.response.MyDataExportResponse; import com.carecode.domain.user.entity.ConsentType; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.entity.UserConsent; @@ -18,9 +19,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** 개인정보 관련 기능: 동의 이력 관리, 내 데이터 열람, 파기. 개인정보보호법상 정보주체는 자신의 정보를 열람하고 처리 정지·삭제를 요구할 수 있다. */ @Slf4j @@ -97,34 +96,18 @@ public List getConsentHistory() { // 데이터 열람 ==================== /** 내 데이터 전체 내려받기. 정보주체의 열람권 행사에 대응한다. 비밀번호 등 인증 정보는 포함하지 않는다. */ - public Map exportMyData() { + public MyDataExportResponse exportMyData() { User user = currentUserFacade.requireCurrentUser(); - Map profile = new LinkedHashMap<>(); - profile.put("userId", user.getUserId()); - profile.put("email", user.getEmail()); - profile.put("name", user.getName()); - profile.put("phoneNumber", user.getPhoneNumber()); - profile.put("birthDate", user.getBirthDate()); - profile.put("address", user.getAddress()); - profile.put("role", user.getRole() != null ? user.getRole().name() : null); - profile.put("createdAt", user.getCreatedAt()); - profile.put("lastLoginAt", user.getLastLoginAt()); - - Map export = new LinkedHashMap<>(); - export.put("exportedAt", LocalDateTime.now()); - export.put("profile", profile); - export.put("children", childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()).stream() - .map(child -> Map.of( - "name", child.getName(), - "birthDate", String.valueOf(child.getBirthDate()), - "gender", String.valueOf(child.getGender()))) - .toList()); - export.put("healthRecordCount", healthRecordRepository.findByUserOrderByRecordDateDesc(user).size()); - export.put("postCount", postRepository.countByAuthorId(user.getId())); - export.put("consentHistory", getConsentHistory()); - - return export; + return new MyDataExportResponse( + LocalDateTime.now(), + MyDataExportResponse.Profile.from(user), + childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()).stream() + .map(MyDataExportResponse.ChildData::from) + .toList(), + healthRecordRepository.findByUserOrderByRecordDateDesc(user).size(), + postRepository.countByAuthorId(user.getId()), + getConsentHistory()); } // ==================== diff --git a/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java b/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java index 3fea1970..2ed647a5 100644 --- a/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java +++ b/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java @@ -1,15 +1,38 @@ package com.carecode.contract; +import com.carecode.domain.admin.dto.response.AdminBookingDashboardResponse; +import com.carecode.domain.admin.dto.response.AdminDashboardResponse; +import com.carecode.domain.admin.dto.response.DeadlineNotifyResponse; +import com.carecode.domain.admin.dto.response.ForecastAccuracyMeasureResponse; +import com.carecode.domain.admin.dto.response.GeocodingResultResponse; +import com.carecode.domain.admin.dto.response.PolicyVerificationResponse; +import com.carecode.domain.admin.dto.response.RegionVerificationStatusResponse; +import com.carecode.domain.admin.dto.response.SyncResultResponse; +import com.carecode.domain.admin.dto.response.SyncStatusResponse; +import com.carecode.domain.admin.dto.response.VacancyNotifyResponse; import com.carecode.domain.community.dto.request.CommunityCreateCommentRequest; import com.carecode.domain.community.dto.request.CommunityCreatePostRequest; +import com.carecode.domain.community.dto.response.PostBookmarkCountResponse; +import com.carecode.domain.community.dto.response.PostBookmarkToggleResponse; +import com.carecode.domain.community.dto.response.PostLikeCountResponse; +import com.carecode.domain.community.dto.response.PostLikeToggleResponse; +import com.carecode.domain.facility.dto.response.WaitlistEntryResponse; +import com.carecode.domain.facility.dto.response.WaitlistRegisterResponse; import com.carecode.domain.health.dto.response.GrowthPointResponse; +import com.carecode.domain.health.dto.response.HealthRecommendationResponse; import com.carecode.domain.notification.dto.response.NotificationInfoResponse; +import com.carecode.domain.user.dto.response.KakaoLoginUrlResponse; +import com.carecode.domain.user.dto.response.MyDataExportResponse; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -48,4 +71,98 @@ void growthZScore() throws Exception { assertThat(json.path("zScore").asDouble()).isEqualTo(1.5); assertThat(json.has("zscore")).isFalse(); } + + /** + * 좋아요·북마크 토글의 키를 고정한다. + * + *

레코드는 컴포넌트 이름을 그대로 키로 쓰므로 {@code isLiked} 는 그대로 나간다. 위험한 + * 쪽은 **클래스로 바꿀 때**다. Lombok {@code @Getter} 의 {@code isLiked()} 는 Jackson 이 + * {@code is} 를 떼어 {@code liked} 로 만든다(이 파일 위쪽의 {@code isRead}·{@code isAnonymous} + * 가 같은 사고였다). 프런트는 {@code isLiked} 를 {@code z.boolean()} 으로 필수로 읽으므로 + * 그 순간 토글이 파싱 단계에서 깨진다. 값이 아니라 키가 틀리는 종류라 눈으로는 안 보인다. + */ + @Test + @DisplayName("좋아요·북마크 토글 응답은 isLiked·isBookmarked 키로 나간다") + void toggleFlagsKeepIsPrefix() throws Exception { + JsonNode like = mapper.valueToTree(new PostLikeToggleResponse(true, 7L)); + assertThat(like.path("isLiked").asBoolean()).isTrue(); + assertThat(like.path("likeCount").asLong()).isEqualTo(7L); + assertThat(like.has("liked")).as("is 접두사가 떨어지면 프런트가 못 읽는다").isFalse(); + + + JsonNode bookmark = mapper.valueToTree(new PostBookmarkToggleResponse(true, 3L)); + assertThat(bookmark.path("isBookmarked").asBoolean()).isTrue(); + assertThat(bookmark.path("bookmarkCount").asLong()).isEqualTo(3L); + assertThat(bookmark.has("bookmarked")).isFalse(); + } + + /** + * {@code Map} 응답을 DTO 로 바꾼 자리들. 바꾸면서 키가 하나라도 달라지면 프런트가 그 값을 + * 조용히 {@code undefined} 로 읽는다(zod 가 {@code nullish} 로 받는 키가 많아 예외도 안 난다). + * 그래서 **키 집합을 그대로** 적어 둔다. + */ + @Test + @DisplayName("Map 에서 DTO 로 바꾼 응답의 키 집합이 그대로다") + void typedResponsesKeepTheirKeys() { + assertKeys(new WaitlistRegisterResponse(1L), "waitlistId"); + + assertKeys(new WaitlistEntryResponse(1L, 2L, 3, LocalDate.now(), "WAITING", "대기 중", 10L), + "waitlistId", "facilityId", "waitNumber", "appliedAt", "status", "statusName", "waitedDays"); + + assertKeys(new PostLikeCountResponse(1L), "likeCount"); + assertKeys(new PostBookmarkCountResponse(1L), "bookmarkCount"); + + assertKeys(KakaoLoginUrlResponse.of("https://kauth.kakao.com/oauth/authorize"), + "success", "loginUrl", "message"); + + assertKeys(new HealthRecommendationResponse("u1", 3, List.of(), List.of(), "안내"), + "userId", "childAge", "recommendedPolicies", "recommendedFacilities", "nudgeMessage"); + + assertKeys(new SyncResultResponse("p", "r", true, 1, 2, 3, 4, 5, null), + "provider", "resource", "completed", "created", "updated", "failed", "skipped", + "pagesProcessed", "stoppedReason"); + + assertKeys(new GeocodingResultResponse(1, 2, 3L, null), + "resolved", "failed", "remaining", "skippedReason"); + + assertKeys(new VacancyNotifyResponse(1, 2, 3), + "facilitiesChecked", "facilitiesWithVacancy", "notificationsSent"); + + assertKeys(new DeadlineNotifyResponse(1, 2), "policiesDueSoon", "notificationsSent"); + + assertKeys(SyncStatusResponse.of(List.of()), "jobs", "staleCount"); + + assertKeys(ForecastAccuracyMeasureResponse.of(List.of()), "measured", "results"); + + assertKeys(new PolicyVerificationResponse(1L, "t", LocalDateTime.now(), "admin@example.com"), + "policyId", "title", "verifiedAt", "verifiedBy"); + + assertKeys(new RegionVerificationStatusResponse("서울", 10, 4, 6, 40), + "region", "total", "verified", "unverified", "verifiedRate"); + + assertKeys(new AdminDashboardResponse(1, 2, 3, List.of(), List.of(), List.of()), + "userCount", "hospitalCount", "policyCount", "recentActivities", + "userTrendLabels", "userTrendData"); + + assertKeys(new AdminDashboardResponse.RecentActivity("user", "가입", "2026-10-05 01:00"), + "type", "desc", "time"); + + assertKeys(new AdminBookingDashboardResponse(null, List.of(), List.of()), + "stats", "recentBookings", "todayBookings"); + + assertKeys(new MyDataExportResponse(LocalDateTime.now(), null, List.of(), 0, 0L, List.of()), + "exportedAt", "profile", "children", "healthRecordCount", "postCount", "consentHistory"); + + assertKeys(new MyDataExportResponse.ChildData("아이", "2024-01-01", "FEMALE"), + "name", "birthDate", "gender"); + } + + /** 키가 빠졌는지와 **새 키가 늘었는지**를 함께 본다. 늘어난 키도 계약 변경이다. */ + private void assertKeys(Object response, String... expected) { + JsonNode json = mapper.valueToTree(response); + assertThat(json.fieldNames()) + .as("%s 의 JSON 키", response.getClass().getSimpleName()) + .toIterable() + .containsExactlyInAnyOrder(expected); + } } diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index 18b8d415..b700b341 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -196,43 +196,66 @@ void privilegedMappingsRemovedFromUserApi(String path) throws Exception { /** * 공공데이터 동기화는 외부 API 한도를 태우고 DB 에 쓴다. 공개로 두면 누구나 실행할 수 있다. * - *

{@code /api/public/care-facilities/**} 가 통째로 permitAll 이라, 그 아래 있는 - * 동기화 트리거까지 열려 있었다. {@code swagger/sync} 는 GET 이라 브라우저 접속이나 - * 크롤러만으로도 실행된다. + *

한때 같은 동기화가 {@code /api/public/care-facilities/**}(통째로 permitAll) 아래에도 + * 있었고, 그중 하나는 GET 이라 브라우저 접속이나 크롤러만으로 실행됐다. 그 중복 경로는 + * 삭제했고(아래 {@code duplicateMappingsRemoved}), 남은 입구는 어드민 경로 하나뿐이다. */ @Test @DisplayName("공공데이터 동기화는 비로그인으로 실행할 수 없다") void publicDataSyncIsNotOpen() throws Exception { - assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn() + assertThat(mockMvc.perform(post("/api/admin/public-data/facilities/sync")).andReturn() .getResponse().getStatus()) .as("POST 동기화가 열려 있으면 안 된다") .isIn(401, 403); - - assertThat(mockMvc.perform(get("/api/public/care-facilities/swagger/sync")).andReturn() - .getResponse().getStatus()) - .as("GET 동기화는 브라우저 접속만으로도 실행된다") - .isIn(401, 403); } @Test @DisplayName("일반 회원도 공공데이터 동기화를 실행할 수 없다") @WithMockUser(username = "member@example.com", roles = "PARENT") void publicDataSyncRequiresAdmin() throws Exception { - assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn() + assertThat(mockMvc.perform(post("/api/admin/public-data/facilities/sync")).andReturn() .getResponse().getStatus()).isEqualTo(403); } - /** 시설·정책 조회는 계속 공개여야 한다. 위 제한이 조회까지 막으면 안 된다. */ + /** 시설 조회는 계속 공개여야 한다. 위 제한이 조회까지 막으면 안 된다. */ @ParameterizedTest(name = "{0} 은 여전히 공개다") @ValueSource(strings = { - "/api/public/care-facilities/swagger/stats", - "/api/public/care-facilities/swagger/db-facilities" + "/facilities", + "/facilities/statistics" }) void publicDataReadStaysOpen(String path) throws Exception { assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) .isNotIn(401, 403); } + /** + * 같은 일을 하는 입구가 여러 개면 보안 설정도 그만큼 중복된다. 실제로 동기화 입구가 둘이어서 + * 한쪽만 ADMIN 으로 잠겨 있었다. + * + *

삭제한 중복 경로가 다시 생기면(복사·붙여넣기로 되살아나기 쉽다) 여기서 걸린다. + * 괄호 안은 같은 일을 하는, 남겨 둔 경로다. + */ + @ParameterizedTest(name = "{0} 매핑은 더 이상 존재하지 않는다") + @ValueSource(strings = { + // → POST /api/admin/public-data/facilities/sync + "/api/public/care-facilities/sync-all", + "/api/public/care-facilities/swagger/sync", + // → GET /facilities, GET /facilities/statistics + "/api/public/care-facilities/swagger/db-facilities", + "/api/public/care-facilities/swagger/stats", + // → GET /notifications/preferences, GET /notifications/stats + "/notifications/settings/user-1", + "/notifications/statistics/user-1" + }) + @WithMockUser(username = "admin@example.com", roles = "ADMIN") + void duplicateMappingsRemoved(String path) throws Exception { + MvcResult result = mockMvc.perform(get(path)).andReturn(); + + assertThat(result.getResponse().getStatus()) + .as("%s 는 매핑이 없어야 한다 (404/405)", path) + .isIn(404, 405); + } + /** 버전 필터가 실제 필터 체인에 걸려 있는지. 단위 테스트만으로는 등록 누락을 모른다. */ @Test @DisplayName("응답에 API 버전 헤더가 붙고, 모르는 버전은 400 이다") diff --git a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java index ea90578f..bea4110d 100644 --- a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java +++ b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java @@ -113,10 +113,6 @@ class EndpointAuthorizationCoverageTest { "GET /community/latest", "GET /community/tags", "GET /community/search/all", "GET /community/popular/limit", "GET /community/latest/limit", - // 공공데이터 조회 (동기화 트리거는 ADMIN 이다) - "GET /api/public/care-facilities/swagger/stats", - "GET /api/public/care-facilities/swagger/db-facilities", - // 조회수 증가. 쓰기지만 비로그인 방문자의 조회도 세야 하므로 공개다. // 다만 이 값이 /facilities/popular 순위에 쓰이므로 부풀릴 수 있다. // 인기 순위를 조작에 민감하게 다뤄야 한다면 여기부터 손봐야 한다.