diff --git a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df index dfccd93..e69de29 100644 --- a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df +++ b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df @@ -1,37 +0,0 @@ -Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardController.java:0) -Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardController.java:0) -Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardController.java:0) -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.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 (AdminDashboardController.java:0) -Field has type in (AdminDashboardController.java:0) -Field has type in (AdminDashboardController.java:0) -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 (NotificationLinkController.java:0) -Field has type in (BenefitLinkController.java:0) -Method calls method in (AdminDashboardController.java:46) -Method calls method in (AdminDashboardController.java:47) -Method calls method in (AdminDashboardController.java:45) -Method calls method in (AdminDashboardController.java:59) -Method calls method in (AdminDashboardController.java:62) -Method calls method in (AdminDashboardController.java:56) -Method calls method in (AdminDashboardController.java:78) -Method calls method in (AdminPolicyController.java:77) -Method calls method in (AdminPolicyController.java:36) -Method calls method in (AdminPolicyVerificationController.java:69) -Method calls method in (AdminPolicyVerificationController.java:57) -Method calls method in (AdminPolicyVerificationController.java:61) -Method calls method in (AdminPolicyVerificationController.java:42) -Method calls method in (AdminPolicyVerificationController.java:48) -Method calls method in (AdminUserController.java:172) -Method calls method in (AdminUserController.java:177) -Method calls method in (AdminUserController.java:56) -Method calls method in (AdminUserController.java:134) -Method calls method in (NotificationLinkController.java:56) -Method calls method in (NotificationLinkController.java:64) -Method calls method in (BenefitLinkController.java:44) diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index c2a1dfc..419c90d 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -32,14 +32,14 @@ admin → facility 리포지토리: Method calls method in (CareFacilityBookingAdminService.java:102) admin → facility 리포지토리: Method calls method in (CareFacilityBookingAdminService.java:142) admin → facility 리포지토리: Method calls method in (CareFacilityBookingAdminService.java:162) -admin → health 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardController.java:0) +admin → health 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardService.java:0) admin → health 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (AdminHealthRecordService.java:0) admin → health 리포지토리: Constructor (com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.health.mapper.HospitalMapper)> has parameter of type in (AdminHospitalService.java:0) -admin → health 리포지토리: Field has type in (AdminDashboardController.java:0) +admin → health 리포지토리: Field has type in (AdminDashboardService.java:0) admin → health 리포지토리: Field has type in (AdminHealthRecordService.java:0) admin → health 리포지토리: Field has type in (AdminHospitalService.java:0) -admin → health 리포지토리: Method calls method in (AdminDashboardController.java:46) -admin → health 리포지토리: Method calls method in (AdminDashboardController.java:59) +admin → health 리포지토리: Method calls method in (AdminDashboardService.java:63) +admin → health 리포지토리: Method calls method in (AdminDashboardService.java:50) admin → health 리포지토리: Method calls method in (AdminHealthRecordService.java:39) admin → health 리포지토리: Method calls method in (AdminHealthRecordService.java:30) admin → health 리포지토리: Method calls method in (AdminHealthRecordService.java:43) @@ -51,50 +51,47 @@ admin → notification 리포지토리: Field calls method in (AdminNotificationService.java:63) admin → notification 리포지토리: Method calls method in (AdminNotificationService.java:39) admin → notification 리포지토리: Method calls method in (AdminNotificationService.java:67) -admin → policy 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardController.java:0) -admin → policy 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.admin.service.PolicyAdminService)> has parameter of type in (AdminPolicyController.java:0) -admin → policy 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (AdminPolicyVerificationController.java:0) +admin → policy 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardService.java:0) admin → policy 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyCategoryRepository, com.fasterxml.jackson.databind.ObjectMapper)> has parameter of type in (PolicyAdminService.java:0) admin → policy 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyCategoryRepository, com.fasterxml.jackson.databind.ObjectMapper)> has parameter of type in (PolicyAdminService.java:0) -admin → policy 리포지토리: Field has type in (AdminDashboardController.java:0) -admin → policy 리포지토리: Field has type in (AdminPolicyController.java:0) -admin → policy 리포지토리: Field has type in (AdminPolicyVerificationController.java:0) +admin → policy 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (PolicyVerificationService.java:0) +admin → policy 리포지토리: Field has type in (AdminDashboardService.java:0) admin → policy 리포지토리: Field has type in (PolicyAdminService.java:0) admin → policy 리포지토리: Field has type in (PolicyAdminService.java:0) -admin → policy 리포지토리: Method calls method in (AdminDashboardController.java:47) -admin → policy 리포지토리: Method calls method in (AdminDashboardController.java:62) -admin → policy 리포지토리: Method calls method in (AdminPolicyController.java:77) -admin → policy 리포지토리: Method calls method in (AdminPolicyController.java:36) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:69) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:57) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:61) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:42) -admin → policy 리포지토리: Method calls method in (AdminPolicyVerificationController.java:48) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:145) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:44) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:170) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:168) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:154) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:71) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:116) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:53) -admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:57) -admin → user 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardController.java:0) -admin → user 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.service.UserService)> has parameter of type in (AdminUserController.java:0) +admin → policy 리포지토리: Field has type in (PolicyVerificationService.java:0) +admin → policy 리포지토리: Method calls method in (AdminDashboardService.java:66) +admin → policy 리포지토리: Method calls method in (AdminDashboardService.java:51) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:160) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:59) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:185) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:183) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:169) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:38) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:86) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:131) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:46) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:68) +admin → policy 리포지토리: Method calls method in (PolicyAdminService.java:72) +admin → policy 리포지토리: Method calls method in (PolicyVerificationService.java:68) +admin → policy 리포지토리: Method calls method in (PolicyVerificationService.java:57) +admin → policy 리포지토리: Method calls method in (PolicyVerificationService.java:52) +admin → policy 리포지토리: Method calls method in (PolicyVerificationService.java:42) +admin → user 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.health.repository.HospitalRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (AdminDashboardService.java:0) admin → user 리포지토리: Constructor (com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.service.NotificationCreationService)> has parameter of type in (AdminNotificationService.java:0) +admin → user 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository)> has parameter of type in (AdminUserService.java:0) admin → user 리포지토리: Constructor (com.carecode.domain.facility.repository.CareFacilityBookingRepository, com.carecode.domain.user.repository.UserRepository)> has parameter of type in (CareFacilityBookingAdminService.java:0) -admin → user 리포지토리: Field has type in (AdminDashboardController.java:0) -admin → user 리포지토리: Field has type in (AdminUserController.java:0) +admin → user 리포지토리: Field has type in (AdminDashboardService.java:0) admin → user 리포지토리: Field has type in (AdminNotificationService.java:0) +admin → user 리포지토리: Field has type in (AdminUserService.java:0) admin → user 리포지토리: Field has type in (CareFacilityBookingAdminService.java:0) -admin → user 리포지토리: Method calls method in (AdminDashboardController.java:45) -admin → user 리포지토리: Method calls method in (AdminDashboardController.java:56) -admin → user 리포지토리: Method calls method in (AdminDashboardController.java:78) -admin → user 리포지토리: Method calls method in (AdminUserController.java:172) -admin → user 리포지토리: Method calls method in (AdminUserController.java:177) -admin → user 리포지토리: Method calls method in (AdminUserController.java:56) -admin → user 리포지토리: Method calls method in (AdminUserController.java:134) +admin → user 리포지토리: Method calls method in (AdminDashboardService.java:60) +admin → user 리포지토리: Method calls method in (AdminDashboardService.java:49) +admin → user 리포지토리: Method calls method in (AdminDashboardService.java:82) admin → user 리포지토리: Method calls method in (AdminNotificationService.java:53) +admin → user 리포지토리: Method calls method in (AdminUserService.java:30) +admin → user 리포지토리: Method calls method in (AdminUserService.java:74) +admin → user 리포지토리: Method calls method in (AdminUserService.java:55) +admin → user 리포지토리: Method calls method in (AdminUserService.java:69) admin → user 리포지토리: Method calls method in (CareFacilityBookingAdminService.java:317) admin → user 리포지토리: Method calls method in (CareFacilityBookingAdminService.java:351) chatbot → facility 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository)> has parameter of type in (CareKnowledgeRetriever.java:0) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index f7a5455..7b08c3f 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -7062,7 +7062,7 @@ }, "/api/admin/users/{id}" : { "delete" : { - "description" : "물리 삭제 대신 soft delete 로 비활성화", + "description" : "물리 삭제하지 않고 개인 식별 정보를 파기한 뒤 비활성화합니다. 본인 탈퇴와 같은 처리입니다", "operationId" : "delete", "parameters" : [ { "in" : "path", diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index 56c4f5a..6dda864 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -31,17 +31,17 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. ## 규칙과 현재 위반 -진척: **489건(0단계) → 391건(1b) → 383건(3a) → 364건(4a) → 357건(5b) → 331건(6단계)**. 남은 것은 -도메인 경계(240)와 컨트롤러의 리포지토리 직접 사용(37), core 기반의 도메인 참조(50)입니다. +진척: **489건(0단계) → 391건(1b) → 364건(4a) → 357건(5b) → 331건(6) → 291건(7단계)**. 남은 것은 +도메인 경계(237)와 core 기반의 도메인 참조(50)입니다. **컨트롤러의 리포지토리 직접 사용은 0 이 됐습니다.** > 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다. > `cat archunit-baseline/* | wc -l` 은 색인 11줄을 함께 세므로 실제 위반보다 11 크게 나옵니다. | 규칙 | 위반 | 뜻 | |------|------|-----| -| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 240 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | +| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 237 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | | 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) | -| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 37 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | +| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | **0** | 7단계에서 해소 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | | core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 | | 매퍼는 리포지토리를 쓰지 않는다 | 3 | 변환 한 번에 숨은 쿼리가 생기면 N+1 을 추적할 수 없다 | | core 에는 컨트롤러가 없다 | **0** | 1a 에서 도메인으로 옮기고, 3a 에서 중복이라 삭제 | @@ -454,6 +454,78 @@ Hibernate 가 flush 를 하지 않으므로 **UPDATE 가 조용히 사라집니 - OpenAPI 경로 수는 그대로(226). `DELETE /api/admin/community/posts/{id}` 의 설명이 "게시글 삭제" → "게시글 숨김" 으로 바뀌었습니다 +## 컨트롤러의 리포지토리 사용이 0 이 됐다 (7단계) + +남은 37건을 옮기면서, 6단계와 같은 방식으로 먼저 각 컨트롤러 안을 봤습니다. **둘 나왔습니다.** + +### 1. 관리자가 처리한 탈퇴는 개인정보를 파기하지 않았다 + +같은 "탈퇴 처리" 에 두 구현이 있었습니다. + +| 경로 | 결과 | +|------|------| +| `DELETE /users/account` (본인) | 이름·이메일·전화·주소·프로필·소셜 ID·좌표를 **지우거나 익명화** + 비활성 + `deletedAt` | +| `DELETE /api/admin/users/{id}` (관리자, "사용자 탈퇴 처리") | **`deletedAt`·`isActive` 만.** 개인정보는 그대로 남는다 | + +이용자가 고객센터로 탈퇴를 요청하면 운영자가 후자를 누릅니다. 그 경우 이름과 이메일이 계속 +남아 있으므로 **"파기했다" 고 말할 수 없습니다.** + +``` +[이메일] +Expecting actual: "d14eb045@example.com" +not to be equal to: "d14eb045@example.com" +``` + +"무엇을 지우는가" 는 그 테이블의 규칙이라 `User.withdraw()` 로 엔티티에 올렸고, 본인 탈퇴와 +관리자 탈퇴가 **같은 메서드**를 씁니다. `userId`(가입 시 발급한 코드)는 남깁니다 — 게시글·알림이 +그 값으로 작성자를 가리키므로 지우면 참조가 끊깁니다. + +### 2. 비로그인 클릭은 한 건도 집계되지 않았다 + +`BenefitLinkController` 는 비로그인 클릭을 집계하도록 쓰여 있었습니다 — 인증 실패를 삼키고 +`null` 로 집계합니다. 그런데 **필터가 먼저 401 을 돌려주고 있었습니다.** + +```java +.requestMatchers("/policies/*").permitAll() // 한 세그먼트만 잡는다 +// → /policies/{id}/apply 는 anyRequest().authenticated() 에 걸린다 +``` + +정책 상세는 공개이고 프런트가 그 화면에 "신청하기" 를 띄우므로, **로그인하지 않은 방문자는 그 +버튼에서 401 을 만났습니다.** 이 전환율은 문서가 "서비스가 실제로 돈을 찾아줬는지 증명하는 유일한 +지표" 라고 적어 둔 값인데, 비로그인 클릭은 집계되지 않았습니다. + +`/notifications/{id}/open` 도 같은 모양이었습니다(`/notifications/**` authenticated). 푸시·메일 +링크를 눌렀을 때 로그인이 만료돼 있으면 딥링크가 통째로 실패합니다. 응답은 쿼리 파라미터로 만든 +딥링크로의 302 뿐이고 알림 내용을 돌려주지 않으므로 둘 다 공개로 바꿨습니다. +(알림 ID 의 존재 여부가 404 로 드러나는 것은 받아들였습니다 — 내용·수신자는 알 수 없습니다.) + +세그먼트 와일드카드 범위를 잘못 읽어 생긴 문제는 이 저장소에서 세 번째입니다 +(`/health/hospitals/likes`, `/community/posts/liked`, 그리고 이번 둘). + +### 함께 고친 것 + +- 온라인 신청 경로가 없는 정책이 `CareServiceException`(**500**)이었습니다. 정상적인 데이터 + 상태이므로 **404** 로 바꿨습니다. 잘못된 스킴이 저장된 경우는 우리 데이터가 틀린 것이라 + 서버 오류로 남깁니다. + +### 옮긴 것 + +| 컨트롤러 | 새/기존 서비스 | +|----------|----------------| +| `AdminDashboardController` | `AdminDashboardService` (user·health·policy 집계) | +| `AdminUserController` | `AdminUserService` (탈퇴는 `User.withdraw()`) | +| `AdminPolicyVerificationController` | `PolicyVerificationService` | +| `AdminPolicyController` | 기존 `PolicyAdminService` 에 list·detail 추가 | +| `NotificationLinkController` | `NotificationLinkService` | +| `BenefitLinkController` | `BenefitLinkService` | + +### 숫자 + +- **컨트롤러→리포지토리 37 → 0.** 이 규칙은 이제 baseline 이 비었습니다 +- ArchUnit baseline 331 → **291** (도메인 경계 240 → 237) +- 전체 테스트 **647개, 실패 0** +- OpenAPI 경로·스키마 수 변화 없음(226 / 158) + ## 다음 단계 | 단계 | 내용 | 효과 | @@ -470,4 +542,5 @@ Hibernate 가 flush 를 하지 않으므로 **UPDATE 가 조용히 사라집니 | ~~5c. 남은 책임 분리~~ | ~~HealthService 688줄~~ | **완료** — 367줄 + 168줄 + 76줄 | | ~~6. 어드민 컨트롤러의 리포지토리 사용~~ | ~~61건 전부가 어드민 컨트롤러에 있었다~~ | **완료** — 61 → 37, 버그 둘 | | 7. 식별 로직을 user 로 | `HealthActorResolver` 가 health 의 마지막 `UserRepository` 사용처다 | 결합 추가 해소 | -| 8. 어드민 조회용 포트 | 남은 37건·도메인 경계 일부. 각 도메인이 어드민 조회 입구를 내주면 사라진다 | 경계 완성 | +| ~~7. 남은 컨트롤러 6개~~ | ~~대시보드·사용자·정책·검증·링크 2개~~ | **완료** — 37 → **0**, 버그 둘 | +| 8. 어드민 조회용 포트 | 도메인 경계 237건 중 어드민 몫. 각 도메인이 어드민 조회 입구를 내주면 사라진다 | 경계 완성 | diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index a3ef835..a4726b2 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -192,6 +192,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 건강 API: 인증된 사용자만 (소유권은 서비스 계층에서 검증) .requestMatchers("/health/**").authenticated() + // 지원금 신청 링크는 비로그인에도 열어야 한다. + // + // /policies/* 는 한 세그먼트만 잡아서 /policies/{id}/apply 는 매칭되지 않고 + // anyRequest().authenticated() 에 걸렸다. 컨트롤러는 비로그인 클릭을 집계하도록 + // 써 있는데(인증 실패를 삼키고 null 로 집계) 필터가 먼저 401 을 돌려주고 있었다. + // 정책 상세는 공개이고 프런트가 그 화면에 "신청하기" 를 띄우므로, 로그인하지 않은 + // 방문자는 그 버튼에서 401 을 만났다. 전환율이 이 서비스의 핵심 지표인데 비로그인 + // 클릭은 한 건도 집계되지 않았다. + .requestMatchers(HttpMethod.GET, "/policies/*/apply").permitAll() + // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. .requestMatchers("/policies/recommendations").authenticated() .requestMatchers("/policies/missed-benefits").authenticated() @@ -238,6 +248,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/**").authenticated() .requestMatchers("/facilities/*/bookings/**").authenticated() .requestMatchers("/community/comments/**").authenticated() + // 알림 열기는 비로그인에도 열어 둔다. 푸시·메일 링크를 눌렀을 때 로그인이 만료돼 + // 있으면 딥링크가 통째로 실패한다. 응답은 쿼리 파라미터로 만든 딥링크로의 302 뿐이고 + // 알림 내용을 돌려주지 않는다. 읽음 표시는 본인 알림일 때만 남긴다. + // (알림 ID 의 존재 여부는 404 로 드러난다 — 내용·수신자는 알 수 없다.) + .requestMatchers(HttpMethod.GET, "/notifications/*/open").permitAll() .requestMatchers("/notifications/**").authenticated() // 기타 모든 요청은 인증 필요 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 397fce8..f78734e 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java @@ -1,13 +1,7 @@ package com.carecode.domain.admin.controller; -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; -import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.admin.service.AdminDashboardService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -16,12 +10,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.stream.Collectors; - /** 어드민 대시보드 API. */ @RestController @RequestMapping("/api/admin") @@ -29,66 +17,11 @@ @Tag(name = "어드민 - 대시보드", description = "관리자 대시보드 요약 API") public class AdminDashboardController { - private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM"); - - private final UserRepository userRepository; - private final HospitalRepository hospitalRepository; - private final PolicyRepository policyRepository; + private final AdminDashboardService adminDashboardService; @GetMapping("/dashboard") @Operation(summary = "대시보드 요약 조회", description = "전체 건수, 최근 활동, 가입자 추이 반환") public ResponseEntity dashboard() { - Map userTrend = userTrend(); - - 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<>(); - - for (User user : userRepository.findTop2ByDeletedAtIsNullOrderByCreatedAtDesc()) { - activities.add(new RecentActivity("user", "신규 사용자 가입: " + user.getName(), format(user.getCreatedAt()))); - } - for (Hospital hospital : hospitalRepository.findTop2ByOrderByCreatedAtDesc()) { - activities.add(new RecentActivity("hospital", "병원 등록: " + hospital.getName(), format(hospital.getCreatedAt()))); - } - for (Policy policy : policyRepository.findTop1ByOrderByCreatedAtDesc()) { - activities.add(new RecentActivity("policy", "정책 등록: " + policy.getTitle(), format(policy.getCreatedAt()))); - } - - return activities.stream() - .sorted(Comparator.comparing(RecentActivity::time).reversed()) - .limit(5) - .collect(Collectors.toList()); - } - - /** 최근 6개월 가입자 추이. 값이 없는 달도 0 으로 채워 그래프가 끊기지 않게 한다. */ - private Map userTrend() { - LocalDate now = LocalDate.now(); - LocalDateTime sixMonthsAgo = now.minusMonths(5).withDayOfMonth(1).atStartOfDay(); - - Map lookup = new HashMap<>(); - for (Object[] row : userRepository.countUsersGroupedByMonthSince(sixMonthsAgo)) { - String key = String.format("%d-%02d", ((Number) row[0]).intValue(), ((Number) row[1]).intValue()); - lookup.put(key, ((Number) row[2]).longValue()); - } - - Map trend = new LinkedHashMap<>(); - for (int i = 5; i >= 0; i--) { - String label = now.minusMonths(i).withDayOfMonth(1).format(MONTH); - trend.put(label, lookup.getOrDefault(label, 0L)); - } - return trend; - } - - private String format(LocalDateTime dateTime) { - return dateTime != null ? dateTime.format(TIMESTAMP) : "-"; + return ResponseEntity.ok(adminDashboardService.summary()); } } diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java index bdc1ee3..2f0763a 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java @@ -1,11 +1,8 @@ package com.carecode.domain.admin.controller; -import com.carecode.core.exception.PolicyNotFoundException; import com.carecode.domain.admin.dto.AdminPolicyDetailResponse; import com.carecode.domain.admin.dto.AdminPolicyRequest; import com.carecode.domain.admin.service.PolicyAdminService; -import com.carecode.domain.policy.entity.Policy; -import com.carecode.domain.policy.repository.PolicyRepository; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,7 +22,6 @@ @Tag(name = "어드민 - 정책", description = "관리자 전용 정책 관리 API") public class AdminPolicyController { - private final PolicyRepository policyRepository; private final PolicyAdminService policyAdminService; @GetMapping @@ -33,13 +29,13 @@ public class AdminPolicyController { description = "수정 요청과 1:1 로 대응하는 원본 값을 반환합니다 (사용자용 PolicyDto 는 가공된 값이라 수정에 쓸 수 없음)") public ResponseEntity> list( @PageableDefault(size = 50, sort = "createdAt") Pageable pageable) { - return ResponseEntity.ok(policyRepository.findAll(pageable).map(AdminPolicyDetailResponse::from)); + return ResponseEntity.ok(policyAdminService.list(pageable)); } @GetMapping("/{id}") @Operation(summary = "정책 상세 조회") public ResponseEntity detail(@PathVariable Long id) { - return ResponseEntity.ok(AdminPolicyDetailResponse.from(findPolicy(id))); + return ResponseEntity.ok(policyAdminService.detail(id)); } @PostMapping @@ -72,9 +68,4 @@ public ResponseEntity delete(@PathVariable Long id) { policyAdminService.delete(id); return ResponseEntity.noContent().build(); } - - private Policy findPolicy(Long id) { - return policyRepository.findById(id) - .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + id)); - } } 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 d31a40e..64e2490 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java @@ -1,80 +1,50 @@ package com.carecode.domain.admin.controller; -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 com.carecode.domain.admin.service.PolicyVerificationService; 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.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -import java.time.LocalDateTime; -import java.util.Comparator; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; /** 정책 금액 검증. 자동 수집된 금액을 사람이 확인한 뒤 확정으로 표시한다. */ -@Slf4j @RestController @RequestMapping("/api/admin/policies") @RequiredArgsConstructor @Tag(name = "어드민 - 정책 검증", description = "지원금 금액 수기 검증") public class AdminPolicyVerificationController { - private final PolicyRepository policyRepository; - private final CurrentUserFacade currentUserFacade; + private final PolicyVerificationService policyVerificationService; @PostMapping("/{policyId}/verify") - @Transactional @Operation(summary = "정책 금액 검증 표시", description = "확인한 금액을 확정으로 전환") public ResponseEntity verify( @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId, @Parameter(description = "금액 근거 출처 URL") @RequestParam(required = false) String sourceUrl) { - - Policy policy = policyRepository.findById(policyId) - .orElseThrow(() -> new ResourceNotFoundException("정책을 찾을 수 없습니다: " + policyId)); - - policy.setVerifiedAt(LocalDateTime.now()); - policy.setVerifiedBy(currentUserFacade.requireCurrentUserEmail()); - policy.setSourceUrl(sourceUrl); - policyRepository.save(policy); - - return ResponseEntity.ok(PolicyVerificationResponse.from(policy)); + return ResponseEntity.ok(policyVerificationService.verify(policyId, sourceUrl)); } @DeleteMapping("/{policyId}/verify") - @Transactional @Operation(summary = "검증 표시 해제", description = "추정치로 되돌림") public ResponseEntity unverify(@PathVariable Long policyId) { - Policy policy = policyRepository.findById(policyId) - .orElseThrow(() -> new ResourceNotFoundException("정책을 찾을 수 없습니다: " + policyId)); - policy.setVerifiedAt(null); - policy.setVerifiedBy(null); - policyRepository.save(policy); + policyVerificationService.unverify(policyId); return ResponseEntity.noContent().build(); } - /** 검증 우선순위 판단용. 미검증 정책이 많은 지역부터 손봐야 한다. */ @GetMapping("/verification-status") @Operation(summary = "지역별 검증 현황", description = "미검증 정책이 많은 지역 순") 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 -> RegionVerificationStatusResponse.of(e.getKey(), e.getValue())) - .sorted(Comparator.comparingInt(RegionVerificationStatusResponse::verifiedRate)) - .toList(); - - return ResponseEntity.ok(rows); + return ResponseEntity.ok(policyVerificationService.statusByRegion()); } } diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java index 749e8ad..1d7c376 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java @@ -1,13 +1,11 @@ package com.carecode.domain.admin.controller; -import com.carecode.core.exception.UserNotFoundException; import com.carecode.core.handler.ApiSuccess; import com.carecode.domain.admin.dto.AdminUserResponse; import com.carecode.domain.admin.dto.AdminUserUpdateRequest; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.dto.response.UserStatsResponse; -import com.carecode.domain.user.entity.User; -import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.admin.service.AdminUserService; import com.carecode.domain.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,10 +16,8 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -44,7 +40,7 @@ @Tag(name = "어드민 - 사용자", description = "관리자 전용 사용자 관리 API") public class AdminUserController { - private final UserRepository userRepository; + private final AdminUserService adminUserService; private final UserService userService; // ==================== 조회 ==================== @@ -53,7 +49,7 @@ public class AdminUserController { @Operation(summary = "사용자 목록 조회") public ResponseEntity> list( @PageableDefault(size = 50, sort = "createdAt") Pageable pageable) { - return ResponseEntity.ok(userRepository.findAll(pageable).map(AdminUserResponse::from)); + return ResponseEntity.ok(adminUserService.list(pageable)); } @GetMapping("/statistics") @@ -105,33 +101,16 @@ public ResponseEntity> usersByRegion(@PathVariable String region) @GetMapping("/{id}") @Operation(summary = "사용자 상세 조회") public ResponseEntity detail(@PathVariable Long id) { - return ResponseEntity.ok(AdminUserResponse.from(findUser(id))); + return ResponseEntity.ok(adminUserService.detail(id)); } // ==================== 변경 ==================== @PatchMapping("/{id}") @Operation(summary = "사용자 정보 수정", description = "이름·연락처·역할·활성 상태만 변경할 수 있습니다") - @Transactional public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody AdminUserUpdateRequest request) { - User user = findUser(id); - - if (request.getName() != null) { - user.setName(request.getName()); - } - if (request.getPhoneNumber() != null) { - user.setPhoneNumber(request.getPhoneNumber()); - } - if (request.getRole() != null) { - user.setRole(request.getRole()); - } - if (request.getIsActive() != null) { - user.setIsActive(request.getIsActive()); - } - user.setUpdatedAt(LocalDateTime.now()); - - return ResponseEntity.ok(AdminUserResponse.from(userRepository.save(user))); + return ResponseEntity.ok(adminUserService.update(id, request)); } @PutMapping("/{id}/role") @@ -162,19 +141,10 @@ public ResponseEntity reactivate(@PathVariable Long id) { } @DeleteMapping("/{id}") - @Operation(summary = "사용자 탈퇴 처리", description = "물리 삭제 대신 soft delete 로 비활성화") - @Transactional + @Operation(summary = "사용자 탈퇴 처리", + description = "물리 삭제하지 않고 개인 식별 정보를 파기한 뒤 비활성화합니다. 본인 탈퇴와 같은 처리입니다") public ResponseEntity delete(@PathVariable Long id) { - User user = findUser(id); - // 하드 삭제하면 게시글·건강기록 등 참조 데이터가 함께 사라지므로 soft delete 로 처리한다. - user.setDeletedAt(LocalDateTime.now()); - user.setIsActive(false); - userRepository.save(user); + adminUserService.withdraw(id); return ResponseEntity.noContent().build(); } - - private User findUser(Long id) { - return userRepository.findById(id) - .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + id)); - } } diff --git a/src/main/java/com/carecode/domain/admin/service/AdminDashboardService.java b/src/main/java/com/carecode/domain/admin/service/AdminDashboardService.java new file mode 100644 index 0000000..338fcce --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/service/AdminDashboardService.java @@ -0,0 +1,98 @@ +package com.carecode.domain.admin.service; + +import com.carecode.domain.admin.dto.response.AdminDashboardResponse; +import com.carecode.domain.admin.dto.response.AdminDashboardResponse.RecentActivity; +import com.carecode.domain.health.entity.Hospital; +import com.carecode.domain.health.repository.HospitalRepository; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 어드민 대시보드 요약. + * + *

세 도메인(user·health·policy)의 리포지토리를 함께 읽는다. 어드민은 본래 전 도메인을 + * 관리하는 화면이라 이 조합 자체는 피할 수 없지만, 조합을 컨트롤러에 두면 화면이 바뀔 때마다 + * 웹 계층을 고치게 된다. 숫자를 세는 일은 여기에 둔다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final UserRepository userRepository; + private final HospitalRepository hospitalRepository; + private final PolicyRepository policyRepository; + + public AdminDashboardResponse summary() { + Map userTrend = userTrend(); + + return new AdminDashboardResponse( + userRepository.count(), + hospitalRepository.count(), + policyRepository.count(), + recentActivities(), + new ArrayList<>(userTrend.keySet()), + new ArrayList<>(userTrend.values())); + } + + private List recentActivities() { + List activities = new ArrayList<>(); + + for (User user : userRepository.findTop2ByDeletedAtIsNullOrderByCreatedAtDesc()) { + activities.add(new RecentActivity("user", "신규 사용자 가입: " + user.getName(), format(user.getCreatedAt()))); + } + for (Hospital hospital : hospitalRepository.findTop2ByOrderByCreatedAtDesc()) { + activities.add(new RecentActivity("hospital", "병원 등록: " + hospital.getName(), format(hospital.getCreatedAt()))); + } + for (Policy policy : policyRepository.findTop1ByOrderByCreatedAtDesc()) { + activities.add(new RecentActivity("policy", "정책 등록: " + policy.getTitle(), format(policy.getCreatedAt()))); + } + + return activities.stream() + .sorted(Comparator.comparing(RecentActivity::time).reversed()) + .limit(5) + .collect(Collectors.toList()); + } + + /** 최근 6개월 가입자 추이. 값이 없는 달도 0 으로 채워 그래프가 끊기지 않게 한다. */ + private Map userTrend() { + LocalDate now = LocalDate.now(); + LocalDateTime sixMonthsAgo = now.minusMonths(5).withDayOfMonth(1).atStartOfDay(); + + Map lookup = new HashMap<>(); + for (Object[] row : userRepository.countUsersGroupedByMonthSince(sixMonthsAgo)) { + String key = String.format("%d-%02d", ((Number) row[0]).intValue(), ((Number) row[1]).intValue()); + lookup.put(key, ((Number) row[2]).longValue()); + } + + Map trend = new LinkedHashMap<>(); + for (int i = 5; i >= 0; i--) { + String label = now.minusMonths(i).withDayOfMonth(1).format(MONTH); + trend.put(label, lookup.getOrDefault(label, 0L)); + } + return trend; + } + + private String format(LocalDateTime dateTime) { + return dateTime != null ? dateTime.format(TIMESTAMP) : "-"; + } +} diff --git a/src/main/java/com/carecode/domain/admin/service/AdminUserService.java b/src/main/java/com/carecode/domain/admin/service/AdminUserService.java new file mode 100644 index 0000000..9855a80 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/service/AdminUserService.java @@ -0,0 +1,77 @@ +package com.carecode.domain.admin.service; + +import com.carecode.core.exception.UserNotFoundException; +import com.carecode.domain.admin.dto.AdminUserResponse; +import com.carecode.domain.admin.dto.AdminUserUpdateRequest; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 어드민 사용자 관리. + * + *

조회·수정·탈퇴 처리를 모았다. 역할 변경·활성화·복구는 사용자 도메인 + * ({@code UserService})이 이미 갖고 있어 그대로 쓴다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminUserService { + + private final UserRepository userRepository; + + public Page list(Pageable pageable) { + return userRepository.findAll(pageable).map(AdminUserResponse::from); + } + + public AdminUserResponse detail(Long id) { + return AdminUserResponse.from(require(id)); + } + + /** 이름·연락처·역할·활성 상태만 바꾼다. 보내지 않은 항목은 유지한다. */ + @Transactional + public AdminUserResponse update(Long id, AdminUserUpdateRequest request) { + User user = require(id); + + if (request.getName() != null) { + user.setName(request.getName()); + } + if (request.getPhoneNumber() != null) { + user.setPhoneNumber(request.getPhoneNumber()); + } + if (request.getRole() != null) { + user.setRole(request.getRole()); + } + if (request.getIsActive() != null) { + user.setIsActive(request.getIsActive()); + } + + return AdminUserResponse.from(userRepository.save(user)); + } + + /** + * 탈퇴 처리. 본인 탈퇴와 같은 구현을 쓴다({@link User#withdraw()}). + * + *

전에는 여기서 {@code deletedAt}·{@code isActive} 만 바꿔, 이름과 이메일이 그대로 + * 남았다. 이용자가 고객센터로 탈퇴를 요청하면 운영자가 이 경로를 누르므로, 그 경우 + * 개인정보가 파기되지 않은 상태였다. + */ + @Transactional + public void withdraw(Long id) { + User user = require(id); + user.withdraw(); + userRepository.save(user); + log.info("관리자 탈퇴 처리 완료 - userId={}", id); + } + + private User require(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + id)); + } +} diff --git a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java index aa0c781..e3a3e08 100644 --- a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java +++ b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java @@ -33,6 +33,21 @@ public class PolicyAdminService { private final PolicyCategoryRepository policyCategoryRepository; private final ObjectMapper objectMapper; + public org.springframework.data.domain.Page list( + org.springframework.data.domain.Pageable pageable) { + return policyRepository.findAll(pageable).map(AdminPolicyDetailResponse::from); + } + + public AdminPolicyDetailResponse detail(Long policyId) { + return AdminPolicyDetailResponse.from(require(policyId)); + } + + private Policy require(Long policyId) { + return policyRepository.findById(policyId) + .orElseThrow(() -> new com.carecode.core.exception.PolicyNotFoundException( + "정책을 찾을 수 없습니다: " + policyId)); + } + @Transactional public AdminPolicyDetailResponse create(AdminPolicyRequest request) { assertPolicyCodeAvailable(request.getPolicyCode(), null); diff --git a/src/main/java/com/carecode/domain/admin/service/PolicyVerificationService.java b/src/main/java/com/carecode/domain/admin/service/PolicyVerificationService.java new file mode 100644 index 0000000..18ba2fb --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/service/PolicyVerificationService.java @@ -0,0 +1,71 @@ +package com.carecode.domain.admin.service; + +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 정책 금액 검증. 자동 수집된 금액을 사람이 확인한 뒤 확정으로 표시한다. + * + *

금액이 "추정치" 에서 "확정" 으로 바뀌는 지점이라 누가 언제 확인했는지를 함께 남긴다. + * 근거 출처를 남기지 않으면 나중에 금액이 달라졌을 때 무엇을 보고 확정했는지 알 수 없다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyVerificationService { + + private final PolicyRepository policyRepository; + private final CurrentUserFacade currentUserFacade; + + @Transactional + public PolicyVerificationResponse verify(Long policyId, String sourceUrl) { + Policy policy = require(policyId); + + policy.setVerifiedAt(LocalDateTime.now()); + policy.setVerifiedBy(currentUserFacade.requireCurrentUserEmail()); + policy.setSourceUrl(sourceUrl); + policyRepository.save(policy); + + return PolicyVerificationResponse.from(policy); + } + + @Transactional + public void unverify(Long policyId) { + Policy policy = require(policyId); + policy.setVerifiedAt(null); + policy.setVerifiedBy(null); + policyRepository.save(policy); + } + + /** 검증 우선순위 판단용. 미검증 정책이 많은 지역부터 손봐야 한다. */ + public List statusByRegion() { + Map> byRegion = policyRepository.findByIsActiveTrue().stream() + .filter(p -> p.getTargetRegion() != null && !p.getTargetRegion().isBlank()) + .collect(Collectors.groupingBy(Policy::getTargetRegion)); + + return byRegion.entrySet().stream() + .map(e -> RegionVerificationStatusResponse.of(e.getKey(), e.getValue())) + .sorted(Comparator.comparingInt(RegionVerificationStatusResponse::verifiedRate)) + .toList(); + } + + private Policy require(Long policyId) { + return policyRepository.findById(policyId) + .orElseThrow(() -> new ResourceNotFoundException("정책을 찾을 수 없습니다: " + policyId)); + } +} diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java index 3173a07..47a9fa6 100644 --- a/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java @@ -1,11 +1,7 @@ package com.carecode.domain.notification.controller; -import com.carecode.core.analytics.EventLogger; -import com.carecode.core.analytics.EventType; -import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.security.CurrentUserFacade; -import com.carecode.domain.notification.entity.Notification; -import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.service.NotificationLinkService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,8 +35,7 @@ public class NotificationLinkController { private static final Set ALLOWED_TARGETS = Set.of( "policies", "policy", "facilities", "facility", "children", "health", "notifications"); - private final NotificationRepository notificationRepository; - private final EventLogger eventLogger; + private final NotificationLinkService notificationLinkService; private final CurrentUserFacade currentUserFacade; @Value("${app.notification.deep-link-base:carecode://}") @@ -53,19 +48,7 @@ public ResponseEntity open( @Parameter(description = "이동할 화면 (policies, facilities 등)") @RequestParam(required = false) String target, @Parameter(description = "대상 식별자") @RequestParam(required = false) String targetId) { - Notification notification = notificationRepository.findById(notificationId) - .orElseThrow(() -> new ResourceNotFoundException("알림을 찾을 수 없습니다: " + notificationId)); - - Long userId = currentUserIdOrNull(); - // 본인 알림만 열람 표시를 남긴다. 남의 알림 ID 로 읽음 처리되면 안 된다. - if (userId != null && notification.getUser() != null - && userId.equals(notification.getUser().getId())) { - notification.setIsRead(true); - notificationRepository.save(notification); - } - - eventLogger.log(EventType.NOTIFICATION_CLICKED, userId, - String.valueOf(notificationId), notification.getNotificationType().name()); + notificationLinkService.recordClick(notificationId, currentUserIdOrNull()); return ResponseEntity.status(HttpStatus.FOUND) .location(URI.create(buildDeepLink(target, targetId))) diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationLinkService.java b/src/main/java/com/carecode/domain/notification/service/NotificationLinkService.java new file mode 100644 index 0000000..d37f927 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/service/NotificationLinkService.java @@ -0,0 +1,48 @@ +package com.carecode.domain.notification.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 알림을 거쳐 들어온 재방문을 집계한다. + * 알림이 실제로 사람을 돌아오게 하는지는 이 전환율로만 알 수 있다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationLinkService { + + private final NotificationRepository notificationRepository; + private final EventLogger eventLogger; + + /** + * 클릭을 집계하고, 본인 알림이면 읽음으로 표시한다. + * + * @param actorUserId 비로그인 클릭도 집계 대상이라 {@code null} 일 수 있다 + * @return 집계한 알림의 유형 + */ + @Transactional + public Notification.NotificationType recordClick(Long notificationId, Long actorUserId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new ResourceNotFoundException("알림을 찾을 수 없습니다: " + notificationId)); + + // 본인 알림만 열람 표시를 남긴다. 남의 알림 ID 로 읽음 처리되면 안 된다. + if (actorUserId != null && notification.getUser() != null + && actorUserId.equals(notification.getUser().getId())) { + notification.setIsRead(true); + notificationRepository.save(notification); + } + + eventLogger.log(EventType.NOTIFICATION_CLICKED, actorUserId, + String.valueOf(notificationId), notification.getNotificationType().name()); + + return notification.getNotificationType(); + } +} diff --git a/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java b/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java index c7f556b..d883848 100644 --- a/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java +++ b/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java @@ -1,12 +1,7 @@ package com.carecode.domain.policy.controller; -import com.carecode.core.analytics.EventLogger; -import com.carecode.core.analytics.EventType; -import com.carecode.core.exception.CareServiceException; -import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.security.CurrentUserFacade; -import com.carecode.domain.policy.entity.Policy; -import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.policy.service.BenefitLinkService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -32,8 +27,7 @@ @Tag(name = "육아 정책", description = "육아 정책 정보 및 검색 API") public class BenefitLinkController { - private final PolicyRepository policyRepository; - private final EventLogger eventLogger; + private final BenefitLinkService benefitLinkService; private final CurrentUserFacade currentUserFacade; @GetMapping("/{policyId}/apply") @@ -41,19 +35,7 @@ public class BenefitLinkController { public ResponseEntity apply( @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { - Policy policy = policyRepository.findById(policyId) - .orElseThrow(() -> new ResourceNotFoundException("정책을 찾을 수 없습니다: " + policyId)); - - String url = policy.getApplicationUrl(); - if (url == null || url.isBlank()) { - throw new CareServiceException("이 정책은 온라인 신청 경로가 없습니다."); - } - // 외부 URL 로 리다이렉트하므로 스킴을 확인한다. javascript: 같은 값이 들어오면 XSS 가 된다. - if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new CareServiceException("허용되지 않은 신청 링크입니다."); - } - - eventLogger.log(EventType.BENEFIT_LINK_CLICKED, currentUserIdOrNull(), String.valueOf(policyId)); + String url = benefitLinkService.resolveApplyUrl(policyId, currentUserIdOrNull()); return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build(); } diff --git a/src/main/java/com/carecode/domain/policy/service/BenefitLinkService.java b/src/main/java/com/carecode/domain/policy/service/BenefitLinkService.java new file mode 100644 index 0000000..4fb56bb --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/BenefitLinkService.java @@ -0,0 +1,54 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 지원금 신청 링크. 클릭을 집계한 뒤 실제 신청처 주소를 돌려준다. + * + *

이 전환율이 서비스가 실제로 돈을 찾아줬는지 증명하는 유일한 지표다. 그래서 프런트는 + * 정책의 주소로 바로 가지 않고 항상 이 경로를 거친다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BenefitLinkService { + + private final PolicyRepository policyRepository; + private final EventLogger eventLogger; + + /** + * 클릭을 집계하고 이동할 주소를 돌려준다. + * + * @param actorUserId 비로그인 클릭도 집계 대상이라 {@code null} 일 수 있다 + * @throws ResourceNotFoundException 정책이 없거나 온라인 신청 경로가 없을 때 + */ + public String resolveApplyUrl(Long policyId, Long actorUserId) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new ResourceNotFoundException("정책을 찾을 수 없습니다: " + policyId)); + + String url = policy.getApplicationUrl(); + // 온라인 신청 경로가 없는 정책은 정상적인 데이터 상태다. 전에는 CareServiceException + // 이라 500 이 나갔고, 그러면 모니터링에 서버 장애로 집계된다. + // (프런트는 주소가 없으면 버튼을 띄우지 않으므로 앱 정상 흐름에서는 닿지 않는다.) + if (url == null || url.isBlank()) { + throw new ResourceNotFoundException("이 정책은 온라인 신청 경로가 없습니다: " + policyId); + } + // 외부 주소로 보내므로 스킴을 확인한다. javascript: 같은 값이 들어오면 XSS 가 된다. + // 이쪽은 우리 DB 에 잘못된 값이 들어온 경우라 서버 오류로 남기는 것이 맞다. + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new IllegalStateException("정책의 신청 링크 스킴이 허용되지 않습니다: policyId=" + policyId); + } + + eventLogger.log(EventType.BENEFIT_LINK_CLICKED, actorUserId, String.valueOf(policyId)); + return url; + } +} diff --git a/src/main/java/com/carecode/domain/user/entity/User.java b/src/main/java/com/carecode/domain/user/entity/User.java index 141c3f5..6146183 100644 --- a/src/main/java/com/carecode/domain/user/entity/User.java +++ b/src/main/java/com/carecode/domain/user/entity/User.java @@ -143,4 +143,33 @@ protected void onCreate() { protected void onUpdate() { updatedAt = LocalDateTime.now(); } + + /** + * 탈퇴 처리. 개인 식별 정보를 지우고 계정을 비활성화한다. + * + *

물리 삭제하지 않는 이유: 게시글·댓글·건강기록 등 참조 데이터가 함께 사라지면 다른 + * 이용자의 대화 맥락이 깨진다. 그래서 행은 남기고 식별 정보만 파기한다. + * + *

엔티티에 두는 이유: "무엇을 지우는가" 는 이 테이블의 규칙이다. 한때 본인 탈퇴 + * ({@code PrivacyService})와 관리자 탈퇴 처리({@code AdminUserController})가 각자 + * 구현했고, 관리자 쪽은 {@code deletedAt}·{@code isActive} 만 바꿔 이름과 이메일이 + * 그대로 남았다. 이용자가 고객센터로 탈퇴를 요청하면 운영자가 그 경로를 누른다. + * + *

{@code userId}(가입 시 발급한 코드)는 남긴다. 게시글·알림이 그 값으로 작성자를 + * 가리키므로 지우면 참조가 끊긴다. + */ + public void withdraw() { + String anonymized = "deleted_" + id; + this.name = "탈퇴한 사용자"; + this.email = anonymized + "@deleted.local"; + this.password = null; + this.phoneNumber = null; + this.address = null; + this.profileImageUrl = null; + this.providerId = null; + this.latitude = null; + this.longitude = null; + this.isActive = false; + this.deletedAt = LocalDateTime.now(); + } } \ No newline at end of file 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 7f38e6f..cc7fcc2 100644 --- a/src/main/java/com/carecode/domain/user/service/PrivacyService.java +++ b/src/main/java/com/carecode/domain/user/service/PrivacyService.java @@ -118,19 +118,8 @@ public MyDataExportResponse exportMyData() { public void deleteMyAccount() { User user = currentUserFacade.requireCurrentUser(); - String anonymized = "deleted_" + user.getId(); - user.setName("탈퇴한 사용자"); - user.setEmail(anonymized + "@deleted.local"); - user.setPassword(null); - user.setPhoneNumber(null); - user.setAddress(null); - user.setProfileImageUrl(null); - user.setProviderId(null); - user.setLatitude(null); - user.setLongitude(null); - user.setIsActive(false); - user.setDeletedAt(LocalDateTime.now()); - + // 무엇을 지우는지는 User 엔티티가 안다. 관리자 탈퇴 처리도 같은 메서드를 쓴다. + user.withdraw(); userRepository.save(user); log.info("회원 탈퇴 처리 완료 - userId={}", user.getId()); } diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index 02bc429..1f97923 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -267,6 +267,28 @@ void duplicateMappingsRemoved(String path) throws Exception { .isIn(404, 405); } + /** + * 전환 집계 링크는 비로그인에도 열려 있어야 한다. + * + *

{@code /policies/*} 는 한 세그먼트만 잡아 {@code /policies/{id}/apply} 가 + * {@code anyRequest().authenticated()} 에 걸려 있었다. 컨트롤러는 비로그인 클릭을 + * 집계하도록 써 있었는데 필터가 먼저 401 을 돌려줬고, 정책 상세가 공개라 로그인하지 않은 + * 방문자는 "신청하기" 에서 401 을 만났다. 전환율이 핵심 지표인데 비로그인 클릭은 한 건도 + * 집계되지 않았다. + * + *

404(없는 id)는 괜찮다. 여기서 보는 것은 "인증 때문에 막히지 않는가" 다. + */ + @ParameterizedTest(name = "{0} 은 비로그인으로도 집계된다") + @ValueSource(strings = { + "/policies/1/apply", + "/notifications/1/open" + }) + void conversionLinksAreOpenToAnonymous(String path) throws Exception { + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .as("%s 가 401 이면 비로그인 클릭이 집계되지 않는다", path) + .isNotIn(401, 403); + } + /** 버전 필터가 실제 필터 체인에 걸려 있는지. 단위 테스트만으로는 등록 누락을 모른다. */ @Test @DisplayName("응답에 API 버전 헤더가 붙고, 모르는 버전은 400 이다") diff --git a/src/test/java/com/carecode/integration/AccountWithdrawalConsistencyTest.java b/src/test/java/com/carecode/integration/AccountWithdrawalConsistencyTest.java new file mode 100644 index 0000000..a25d573 --- /dev/null +++ b/src/test/java/com/carecode/integration/AccountWithdrawalConsistencyTest.java @@ -0,0 +1,129 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; + +/** + * 탈퇴 처리의 결과는 누가 눌렀는지와 무관해야 한다. + * + *

같은 "탈퇴 처리" 에 두 구현이 있었다. + * + *

    + *
  • {@code DELETE /users/account} (본인) — {@code PrivacyService.deleteMyAccount} 가 + * 이름·이메일·전화·주소·프로필·소셜 ID·좌표를 지우거나 익명화한 뒤 비활성화한다.
  • + *
  • {@code DELETE /api/admin/users/{id}} (관리자) — 컨트롤러가 + * {@code deletedAt}·{@code isActive} 만 바꿨다. 개인정보는 그대로 남는다.
  • + *
+ * + *

이용자가 고객센터로 탈퇴를 요청하면 운영자가 후자를 누른다. 그 경우 이름과 이메일이 + * 계속 남아 있으므로 "파기했다" 고 말할 수 없다. 두 경로가 같은 구현을 쓰게 만든다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:carecode_withdrawal;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("탈퇴 처리 일관성") +class AccountWithdrawalConsistencyTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + + private User admin; + private User member; + private String originalEmail; + + @BeforeEach + void setUp() { + admin = saveUser(UserRole.ADMIN); + member = saveUser(UserRole.PARENT); + originalEmail = member.getEmail(); + } + + @Test + @DisplayName("관리자가 처리한 탈퇴도 개인정보를 파기한다") + void adminWithdrawalErasesPersonalData() throws Exception { + int status = mockMvc.perform(delete("/api/admin/users/{id}", member.getId()) + .header("Authorization", "Bearer " + token(admin))) + .andReturn().getResponse().getStatus(); + + assertThat(status).isEqualTo(204); + + User after = userRepository.findById(member.getId()).orElseThrow(); + + assertThat(after.getDeletedAt()).as("탈퇴 시각").isNotNull(); + assertThat(after.getIsActive()).as("비활성화").isFalse(); + + // 본인 탈퇴와 같아야 한다. 남아 있으면 "파기했다" 고 말할 수 없다. + assertThat(after.getEmail()).as("이메일").isNotEqualTo(originalEmail); + assertThat(after.getName()).as("이름").isEqualTo("탈퇴한 사용자"); + assertThat(after.getPhoneNumber()).as("전화번호").isNull(); + assertThat(after.getAddress()).as("주소").isNull(); + } + + private String token(User user) { + return jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + } + + private User saveUser(UserRole role) { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("사용자" + id) + .phoneNumber("010-1234-5678") + .address("서울특별시 성동구") + .role(role) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } +} diff --git a/src/test/java/com/carecode/integration/ClientErrorStatusTest.java b/src/test/java/com/carecode/integration/ClientErrorStatusTest.java index 5f1e2ee..47c98b9 100644 --- a/src/test/java/com/carecode/integration/ClientErrorStatusTest.java +++ b/src/test/java/com/carecode/integration/ClientErrorStatusTest.java @@ -111,6 +111,22 @@ void invalidParameterValueIsBadRequest() throws Exception { verify(alerter, never()).alert(anyString(), anyString(), anyString()); } + /** + * 데이터가 없는 상태는 404 다. + * + *

온라인 신청 경로가 없는 정책은 정상적인 데이터 상태인데 {@code CareServiceException} + * 이라 500 이 나갔다. 모니터링에 서버 장애로 집계되고, 클라이언트는 "이 정책은 온라인 + * 신청이 없다" 와 "서버가 고장났다" 를 구분할 수 없다. + */ + @Test + @DisplayName("없는 정책의 신청 링크는 404 이고 운영 알림을 울리지 않는다") + void missingApplyLinkIsNotFound() throws Exception { + assertThat(status(get("/policies/{id}/apply", 99_999_999L))) + .as("없는 정책의 신청 링크").isEqualTo(404); + + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + private int status(RequestBuilder request) throws Exception { MvcResult result = mockMvc.perform(request).andReturn(); return result.getResponse().getStatus(); diff --git a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java index bea4110..aafac6c 100644 --- a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java +++ b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java @@ -113,6 +113,12 @@ class EndpointAuthorizationCoverageTest { "GET /community/latest", "GET /community/tags", "GET /community/search/all", "GET /community/popular/limit", "GET /community/latest/limit", + // 전환 집계 링크. 비로그인 클릭도 세야 하므로 공개다. + // /policies/{id}/apply 는 신청처로 302 만 하고, /notifications/{id}/open 은 + // 쿼리 파라미터로 만든 딥링크로 302 한다(알림 내용은 돌려주지 않는다). + "GET /policies/1/apply", + "GET /notifications/1/open", + // 조회수 증가. 쓰기지만 비로그인 방문자의 조회도 세야 하므로 공개다. // 다만 이 값이 /facilities/popular 순위에 쓰이므로 부풀릴 수 있다. // 인기 순위를 조작에 민감하게 다뤄야 한다면 여기부터 손봐야 한다.