diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index c56254a4..8f3e859a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -43,17 +43,18 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup SSH key - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} - + # 이 잡은 SSH 를 사용하지 않는다. PR 트리거에서도 도는 잡에 + # 배포용 개인키를 올리지 않도록 SSH 설정을 두지 않는다. + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - + - name: Cache Gradle packages uses: actions/cache@v4 with: @@ -66,10 +67,18 @@ jobs: - name: Run tests run: ./gradlew clean test jacocoTestReport - + + - name: Publish test report + uses: mikepenz/action-junit-report@v5 + if: always() + with: + report_paths: 'build/test-results/test/TEST-*.xml' + + # build 에서 -x test 로 테스트를 빼면 check 에 걸어둔 커버리지 검증까지 함께 무력화된다. + # 위에서 이미 test 를 돌렸으므로 여기서는 검증을 포함해 그대로 build 한다. - name: Build application - run: ./gradlew build -x test - + run: ./gradlew build + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -89,25 +98,35 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup SSH key - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.30.0 + # 이 잡도 SSH 를 사용하지 않으므로 배포용 개인키를 로드하지 않는다. + + # 1) SARIF 리포트 생성 (Security 탭 업로드용) - 여기서는 실패시키지 않는다. + - name: Run Trivy vulnerability scanner (report) + uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: 'fs' scan-ref: '.' format: 'sarif' output: 'trivy-results.sarif' - + - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: 'trivy-results.sarif' + # 2) 게이트: CRITICAL/HIGH 취약점이 있으면 파이프라인을 실패시킨다. + # exit-code 를 지정하지 않으면 취약점이 나와도 그냥 통과해 스캔이 장식이 된다. + - name: Fail on CRITICAL/HIGH vulnerabilities + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: 'fs' + scan-ref: '.' + format: 'table' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + exit-code: '1' + # =========================================== # Build Docker Image Job # =========================================== @@ -208,35 +227,48 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + # blue/green 두 컨테이너는 서로 다른 포트를 점유해야 한다. + # 둘 다 8082 를 쓰면 동시에 뜰 수 없어 무중단 전환 자체가 성립하지 않는다. - name: Determine deployment strategy id: strategy run: | CURRENT_ENV=$(curl -fsS "${{ secrets.PRODUCTION_ROUTER_STATUS_URL }}" || echo "blue") if [ "$CURRENT_ENV" = "blue" ]; then - echo "target-env=green" >> $GITHUB_OUTPUT - echo "current-env=blue" >> $GITHUB_OUTPUT + echo "target-env=green" >> $GITHUB_OUTPUT + echo "current-env=blue" >> $GITHUB_OUTPUT + echo "target-port=8083" >> $GITHUB_OUTPUT else - echo "target-env=blue" >> $GITHUB_OUTPUT + echo "target-env=blue" >> $GITHUB_OUTPUT echo "current-env=green" >> $GITHUB_OUTPUT + echo "target-port=8082" >> $GITHUB_OUTPUT fi - + - name: Deploy to target environment run: | test -n "${{ secrets.PRODUCTION_DEPLOY_HOST }}" + TARGET="${{ steps.strategy.outputs.target-env }}" + PORT="${{ steps.strategy.outputs.target-port }}" ssh -o StrictHostKeyChecking=no ${{ secrets.PRODUCTION_DEPLOY_USER }}@${{ secrets.PRODUCTION_DEPLOY_HOST }} \ - "docker pull ${{ needs.build-docker.outputs.image-tag }} && docker stop carecode-${{ steps.strategy.outputs.target-env }} || true && docker rm carecode-${{ steps.strategy.outputs.target-env }} || true && docker run -d --name carecode-${{ steps.strategy.outputs.target-env }} -p 8082:8082 --env-file /opt/carecode/.env ${{ needs.build-docker.outputs.image-tag }}" - - - name: Wait for deployment to be ready + "docker pull ${{ needs.build-docker.outputs.image-tag }} \ + && (docker stop carecode-$TARGET || true) \ + && (docker rm carecode-$TARGET || true) \ + && docker run -d --name carecode-$TARGET -p $PORT:8082 --env-file /opt/carecode/.env ${{ needs.build-docker.outputs.image-tag }}" + + # 전환 전에는 아직 라우팅되지 않는 대기(target) 인스턴스를 직접 확인해야 한다. + # 공용 헬스 URL 을 보면 구버전이 UP 이라 항상 통과해버린다. + - name: Wait for target instance to be ready run: | - test -n "$PRODUCTION_HEALTH_URL" + test -n "${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }}" + PORT="${{ steps.strategy.outputs.target-port }}" + TARGET_HEALTH_URL=$(echo "${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }}" | sed "s/{port}/$PORT/") for i in {1..20}; do - if curl -fsS "$PRODUCTION_HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then - echo "Production target is healthy" + if curl -fsS "$TARGET_HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then + echo "Production target ($PORT) is healthy" exit 0 fi sleep 5 done - echo "Production health check failed" + echo "Production target health check failed" exit 1 - name: Switch traffic to new environment diff --git a/README.md b/README.md index f7d68d23..3cd7551f 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ docker-compose up carecode-mariadb carecode-redis -d | 정책 | `/policies/*` | 정책 조회, 검색 | | 알림 | `/notifications/*` | 알림 조회, 설정 | | 챗봇 | `/chatbot/*` | 챗봇 대화 | -| 관리자 | `/admin/*` | 관리자 기능 | +| 관리자 | `/api/admin/*` | 관리자 기능 (JWT + `ROLE_ADMIN` 필요) | ### 인증 diff --git a/build.gradle b/build.gradle index a0f6e6b1..d734674e 100644 --- a/build.gradle +++ b/build.gradle @@ -69,6 +69,12 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // FCM 푸시 알림 (서비스 계정 자격증명이 없으면 런타임에 비활성화됨) + implementation 'com.google.firebase:firebase-admin:9.4.1' + + // Claude API (챗봇). API 키가 없으면 룰 기반 응답으로 폴백한다. + implementation 'com.anthropic:anthropic-java:2.34.0' + // Logging - JSON 형식 로깅 지원 implementation 'net.logstash.logback:logstash-logback-encoder:7.4' @@ -99,13 +105,34 @@ tasks.named('check') { } tasks.named('jacocoTestCoverageVerification') { + dependsOn tasks.named('test') + violationRules { + // 1) 전체 커버리지: 후퇴 방지선(ratchet). + // 현재 라인 커버리지는 약 16% 다. 테스트를 늘릴 때마다 이 값을 함께 올린다. + // (기존 0.01 은 사실상 게이트가 없는 것과 같아 통과해도 의미가 없었다.) rule { element = 'BUNDLE' limit { counter = 'LINE' value = 'COVEREDRATIO' - minimum = 0.01 + minimum = 0.15 + } + } + + // 2) 보안·인증 핵심 클래스는 별도의 높은 기준을 적용한다. + // 이 경로들이 테스트 없이 수정되면 빌드가 실패해야 한다. + rule { + element = 'CLASS' + includes = [ + 'com.carecode.domain.user.service.JwtService', + 'com.carecode.core.util.ClientIpResolver', + 'com.carecode.core.util.PageRequestUtil' + ] + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.60 } } } diff --git a/docs/ISSUE_MANAGEMENT.md b/docs/ISSUE_MANAGEMENT.md new file mode 100644 index 00000000..89b58664 --- /dev/null +++ b/docs/ISSUE_MANAGEMENT.md @@ -0,0 +1,109 @@ +# 이슈 운영 가이드 + +이슈가 늘어나도 분류 기준이 흔들리지 않도록 라벨·타입·마일스톤·관계의 사용 규칙을 고정한다. + +## 제목 + +`.github/ISSUE_TEMPLATE` 규칙을 따른다. + +``` +[ 영역 ] 내용 +``` + +- 대괄호 안에 **공백**을 넣는다. `[ Auth ]` (O), `[Auth]` (X) +- 영역은 도메인 또는 관심사. `Core` `Auth` `Infra` `Booking` `Notification` `Child` `Policy` `Search` `PublicData` `CI` +- 커밋 메시지 규칙(`TYPE : 한글 설명 (#이슈번호)`)과는 별개다. 섞지 않는다. + +## Type (GitHub Issue Type) + +한 이슈에 하나만 붙는다. 라벨 `type:*` 과 중복돼 보이지만, Type 은 **GitHub 전역 집계·필터**에 쓰이고 `type:*` 라벨은 저장소 안에서 더 세분화된 성격을 나타낸다. + +| Type | 기준 | +|---|---| +| `Bug` | 지금 동작이 잘못됐다. 기동 실패, 잘못된 결과, 보안 결함, 설정이 효력 없음 | +| `Feature` | 없던 것을 만든다 | +| `Task` | 동작은 그대로인데 주변을 정비한다. 테스트, CI, 문서, 리팩토링, 트래킹 | + +성능 결함은 **의도한 결과가 나오지 않는 것**이면 `Bug`, 단순히 더 빠르게 만드는 것이면 `Task` 다. + +## 라벨 + +### priority + +실행 순서를 정하는 유일한 기준이다. 담당자가 다음에 뭘 할지 고를 때 이것만 본다. + +| 라벨 | 기준 | 대응 | +|---|---|---| +| `priority:P0` | 서비스가 지금 망가져 있다. 기동 불가, 인가 우회, 데이터 유출 | 즉시, 다른 작업 중단 | +| `priority:P1` | 사용자가 체감하는 오류이거나 배포를 막는다 | 현재 스프린트 안 | +| `priority:P2` | 개선하면 좋지만 미루어도 손해가 누적되지 않는다 | 여유 있을 때 | + +판단이 갈리면 **"내일 아침 배포를 막을 일인가"** 를 묻는다. 막으면 P0/P1. + +### domain + +건드리는 코드의 위치. 한 이슈가 여러 도메인에 걸치면 **가장 무게가 실린 쪽 하나**만 붙인다. + +| 라벨 | 범위 | +|---|---| +| `domain:core` | 공통 유틸, 보안 필터, 전역 예외, 설정 클래스 | +| `domain:api` | 컨트롤러 계약, 요청/응답 스키마, 페이징 | +| `domain:infra` | 빌드, CI/CD, 배포, Docker, 마이그레이션 실행 | +| `domain:data` | 시드 데이터, 외부 데이터 연동, 마이그레이션 내용 | +| `domain:health` | 건강 기록, 예방접종, 성장, 병원 | +| `domain:search` | 검색, 추천, 랭킹 | + +### type + +Type 필드를 세분화한다. 복수 부착 가능. + +`type:bug` `type:feature` `type:perf` `type:security` `type:test` `type:tech-debt` + +`type:security` 는 항상 `priority:P0` 또는 `P1` 이다. 보안 결함에 P2 를 붙이지 않는다. + +## 마일스톤 + +**언제 나가야 하는가**로 나눈다. 크기나 난이도가 아니다. + +| 마일스톤 | 기준 | +|---|---| +| `Hotfix` | 다음 배포를 기다릴 수 없다. P0 는 여기로 | +| `API Stabilization` | 클라이언트가 붙기 전에 계약이 확정돼야 한다 | +| `Refactor Sprint` | 동작을 바꾸지 않고 구조를 정리한다 | +| `Production Readiness` | 운영에 올리기 전에 갖춰야 한다. 관측, 배포, 테스트, 데이터 | + +## Relationships (하위 이슈) + +여러 이슈에 걸친 작업은 **상위 트래킹 이슈**를 만들고 하위 이슈로 연결한다. 본문에 번호를 나열만 하지 말고 실제 sub-issue 로 등록한다 — 진행률이 자동 집계된다. + +- 상위 이슈는 `Task` 타입 +- 상위 이슈 본문의 마지막 To Do 는 항상 `PR 머지 후 본 이슈 종료` +- 하위 이슈는 **독립적으로 닫을 수 있는 단위**여야 한다. 나눌 수 없으면 하나로 둔다 + +예: #40 (검토 제안 트래킹) ← #41~#52 / #64 (백엔드 전수 점검) ← #53~#59, #61, #63 + +## 신규 이슈 체크리스트 + +- [ ] 제목이 `[ 영역 ] 내용` 형식인가 +- [ ] Type 을 지정했는가 +- [ ] `priority:*` 를 붙였는가 (없으면 실행 순서에서 누락된다) +- [ ] `domain:*` 을 하나 붙였는가 +- [ ] 마일스톤을 배정했는가 +- [ ] 프로젝트 보드에 올렸는가 +- [ ] 상위 작업의 일부라면 sub-issue 로 연결했는가 +- [ ] To Do 가 **검증 가능한 단위**로 쪼개져 있는가 + +## 종료 기준 + +- 계획한 To Do 가 전부 체크됐고 코드가 기본 브랜치에 올라갔을 때 닫는다 +- PR 로 해소되는 이슈는 PR 본문에 `Closes #번호` 를 넣어 **머지 시 자동으로 닫히게** 한다. 수동으로 미리 닫지 않는다 +- **부분 완료는 닫지 않는다.** 완료된 To Do 를 체크하고 남은 범위를 코멘트로 명시한 뒤 열어 둔다 +- 다른 이슈가 같은 범위를 완전히 흡수했으면 `duplicate` 사유로 닫고 흡수한 이슈를 링크한다 + +## 정기 점검 + +월 1회 다음을 확인한다. + +- Type/`priority:*`/마일스톤이 비어 있는 열린 이슈 +- 90일 이상 갱신 없는 이슈 — 여전히 유효한지, 아니면 `not planned` 로 닫을지 +- 상위 트래킹 이슈의 하위 진행률과 본문 To Do 가 어긋나지 않는지 diff --git a/src/main/java/com/carecode/core/RateLimitInterceptor.java b/src/main/java/com/carecode/core/RateLimitInterceptor.java index e6a70120..308b0097 100644 --- a/src/main/java/com/carecode/core/RateLimitInterceptor.java +++ b/src/main/java/com/carecode/core/RateLimitInterceptor.java @@ -1,9 +1,11 @@ package com.carecode.core; +import com.carecode.core.util.ClientIpResolver; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; @@ -29,6 +31,7 @@ public class RateLimitInterceptor implements HandlerInterceptor { private final StringRedisTemplate redisTemplate; + private final ClientIpResolver clientIpResolver; private static final String RATE_LIMIT_KEY_PREFIX = "ratelimit:"; private static final int AUTHENTICATED_LIMIT = 300; // 인증 사용자 (userId 기준) @@ -57,14 +60,19 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons private boolean checkLimit(String keyBody, int limit, HttpServletResponse response) throws Exception { String key = RATE_LIMIT_KEY_PREFIX + keyBody; - Long currentCount = redisTemplate.opsForValue().increment(key); - - if (currentCount != null && currentCount == 1) { - redisTemplate.expire(key, WINDOW_DURATION); + long count; + try { + Long currentCount = redisTemplate.opsForValue().increment(key); + if (currentCount != null && currentCount == 1) { + redisTemplate.expire(key, WINDOW_DURATION); + } + count = currentCount != null ? currentCount : 0; + } catch (DataAccessException e) { + // Redis 장애가 전체 API 중단으로 번지지 않도록 fail-open 한다. + log.error("Rate limit 확인 실패 - Redis 장애로 제한을 건너뜁니다. key={}", key, e); + return true; } - long count = currentCount != null ? currentCount : 0; - if (count > limit) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json;charset=UTF-8"); @@ -92,29 +100,11 @@ private String resolveUserId() { } /** - * 클라이언트 IP 추출 (프록시 고려) + * 클라이언트 IP 추출. + * 프록시 헤더 신뢰 여부는 {@link ClientIpResolver} 가 설정에 따라 판단한다. */ private String getClientIp(HttpServletRequest request) { - String ip = request.getHeader("X-Forwarded-For"); - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("Proxy-Client-IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("WL-Proxy-Client-IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("HTTP_CLIENT_IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("HTTP_X_FORWARDED_FOR"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getRemoteAddr(); - } - if (ip != null && ip.contains(",")) { - ip = ip.split(",")[0].trim(); - } - return ip; + return clientIpResolver.resolve(request); } /** diff --git a/src/main/java/com/carecode/core/annotation/CacheableResult.java b/src/main/java/com/carecode/core/annotation/CacheableResult.java deleted file mode 100644 index 1c566d8d..00000000 --- a/src/main/java/com/carecode/core/annotation/CacheableResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.carecode.core.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * 결과 캐싱을 위한 어노테이션 - * 육아 정책 정보, 시설 정보 등 자주 조회되는 데이터의 성능 향상에 활용 - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface CacheableResult { - String cacheName() default ""; - long ttl() default 3600; // 기본 1시간 - String key() default ""; -} \ No newline at end of file diff --git a/src/main/java/com/carecode/core/aspect/CachingAspect.java b/src/main/java/com/carecode/core/aspect/CachingAspect.java deleted file mode 100644 index c97a297e..00000000 --- a/src/main/java/com/carecode/core/aspect/CachingAspect.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.carecode.core.aspect; - -import com.carecode.core.annotation.CacheableResult; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.type.TypeFactory; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.Signature; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -import java.lang.reflect.Method; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.util.concurrent.TimeUnit; - -/** - * Redis를 사용한 캐싱 Aspect - * @CacheableResult 어노테이션이 붙은 메서드의 결과를 Redis에 캐싱합니다. - */ -@Aspect -@Component -@Slf4j -@RequiredArgsConstructor -public class CachingAspect { - - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Around("@annotation(cacheableResult)") - public Object cacheResult(ProceedingJoinPoint joinPoint, CacheableResult cacheableResult) throws Throwable { - String cacheKey = generateCacheKey(joinPoint, cacheableResult); - - try { - // Redis에서 캐시 확인 - String cachedValue = redisTemplate.opsForValue().get(cacheKey); - if (cachedValue != null) { - log.debug("캐시 히트 - Key: {}", cacheKey); - try { - Signature signature = joinPoint.getSignature(); - if (signature instanceof MethodSignature) { - MethodSignature methodSignature = (MethodSignature) signature; - Method method = methodSignature.getMethod(); - Type returnType = method.getGenericReturnType(); - return deserialize(cachedValue, returnType); - } - } catch (Exception e) { - log.warn("캐시 역직렬화 실패 - Key: {}, 오류: {}", cacheKey, e.getMessage()); - // 역직렬화 실패 시 캐시 삭제하고 메서드 실행 - redisTemplate.delete(cacheKey); - } - } - - // 메서드 실행 - Object result = joinPoint.proceed(); - - // Redis에 캐싱 - String serializedValue = serialize(result); - redisTemplate.opsForValue().set( - cacheKey, - serializedValue, - cacheableResult.ttl(), - TimeUnit.SECONDS - ); - - log.debug("캐시 저장 - Key: {}, TTL: {}초", cacheKey, cacheableResult.ttl()); - return result; - - } catch (Exception e) { - log.error("캐싱 처리 중 오류 발생 - Key: {}", cacheKey, e); - // 캐싱 실패 시에도 메서드는 정상 실행 - return joinPoint.proceed(); - } - } - - private String generateCacheKey(ProceedingJoinPoint joinPoint, CacheableResult cacheableResult) { - if (!cacheableResult.key().isEmpty()) { - return cacheableResult.cacheName() + ":" + cacheableResult.key(); - } - - String methodName = joinPoint.getSignature().getName(); - String className = joinPoint.getTarget().getClass().getSimpleName(); - Object[] args = joinPoint.getArgs(); - - StringBuilder keyBuilder = new StringBuilder(); - keyBuilder.append(cacheableResult.cacheName()) - .append(":") - .append(className) - .append(".") - .append(methodName); - - if (args != null && args.length > 0) { - keyBuilder.append(":"); - for (int i = 0; i < args.length; i++) { - if (i > 0) keyBuilder.append(","); - keyBuilder.append(args[i] != null ? args[i].toString() : "null"); - } - } - - return keyBuilder.toString(); - } - - private String serialize(Object obj) throws Exception { - return objectMapper.writeValueAsString(obj); - } - - @SuppressWarnings("unchecked") - private T deserialize(String json, Type returnType) throws Exception { - if (returnType instanceof Class) { - return (T) objectMapper.readValue(json, (Class) returnType); - } else if (returnType instanceof ParameterizedType) { - ParameterizedType parameterizedType = (ParameterizedType) returnType; - TypeFactory typeFactory = objectMapper.getTypeFactory(); - return (T) objectMapper.readValue(json, typeFactory.constructType(returnType)); - } else { - // 기본적으로 Object로 역직렬화 - return (T) objectMapper.readValue(json, Object.class); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java index afd83676..d0ba6685 100644 --- a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java +++ b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java @@ -1,108 +1,80 @@ package com.carecode.core.aspect; import com.carecode.core.annotation.RateLimit; -import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.RateLimitExceededException; +import com.carecode.core.util.ClientIpResolver; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; +import java.time.Duration; /** - * Rate Limiting을 처리하는 Aspect - * 지정된 시간 내에 허용되는 최대 요청 수를 제한합니다. + * {@link RateLimit} 이 붙은 메서드에 대한 요청 수 제한. + * + *

이전 구현은 인스턴스 로컬 {@code ConcurrentHashMap} 을 썼기 때문에 + * (1) 다중 인스턴스에서 무의미했고, (2) IP 별 키가 무한히 쌓여 메모리를 잠식했으며, + * (3) 윈도우 리셋이 원자적이지 않아 경계에서 한도를 초과할 수 있었다. + * Redis 의 INCR + EXPIRE 로 교체해 세 문제를 모두 해소한다. */ @Aspect @Component @Slf4j +@RequiredArgsConstructor public class RateLimitingAspect { - private final ConcurrentHashMap rateLimitMap = new ConcurrentHashMap<>(); + private static final String KEY_PREFIX = "ratelimit:method:"; + + private final StringRedisTemplate redisTemplate; + private final ClientIpResolver clientIpResolver; @Around("@annotation(rateLimit)") public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { - String key = generateKey(joinPoint, rateLimit); - RateLimitInfo info = rateLimitMap.computeIfAbsent(key, k -> new RateLimitInfo(rateLimit)); - - long currentTime = System.currentTimeMillis(); - - // 시간 윈도우가 지났으면 리셋 - if (currentTime - info.getWindowStart() > rateLimit.windowSeconds() * 1000L) { - info.reset(currentTime); + String key = KEY_PREFIX + generateKey(joinPoint, rateLimit); + + long count; + try { + Long incremented = redisTemplate.opsForValue().increment(key); + count = incremented != null ? incremented : 0L; + if (count == 1L) { + redisTemplate.expire(key, Duration.ofSeconds(rateLimit.windowSeconds())); + } + } catch (DataAccessException e) { + // Redis 장애로 전체 API 가 막히지 않도록 fail-open 한다. + log.error("Rate limit 카운터 조회 실패 - Redis 장애로 제한을 건너뜁니다. key={}", key, e); + return joinPoint.proceed(); } - - // 요청 수 체크 - int currentCount = info.getCount().incrementAndGet(); - - if (currentCount > rateLimit.requests()) { - log.warn("Rate limit 초과 - Key: {}, 현재 요청 수: {}, 허용 한도: {}", - key, currentCount, rateLimit.requests()); - throw new BusinessException(rateLimit.message()); + + if (count > rateLimit.requests()) { + log.warn("Rate limit 초과 - key={}, 요청 수={}, 한도={}", key, count, rateLimit.requests()); + throw new RateLimitExceededException(rateLimit.message()); } - + return joinPoint.proceed(); } - + private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { String methodName = joinPoint.getSignature().toShortString(); - - if (rateLimit.perUser()) { - // IP 기반 키 생성 - ServletRequestAttributes attributes = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attributes != null) { - HttpServletRequest request = attributes.getRequest(); - String clientIp = getClientIp(request); - return methodName + ":" + clientIp; - } - } - - return methodName; - } - - private String getClientIp(HttpServletRequest request) { - String ip = request.getHeader("X-Forwarded-For"); - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("Proxy-Client-IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("WL-Proxy-Client-IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getRemoteAddr(); - } - return ip; - } - - private static class RateLimitInfo { - private final RateLimit rateLimit; - private long windowStart; - private final AtomicInteger count; - - public RateLimitInfo(RateLimit rateLimit) { - this.rateLimit = rateLimit; - this.windowStart = System.currentTimeMillis(); - this.count = new AtomicInteger(0); - } - - public void reset(long currentTime) { - this.windowStart = currentTime; - this.count.set(0); - } - - public long getWindowStart() { - return windowStart; + + if (!rateLimit.perUser()) { + return methodName; } - - public AtomicInteger getCount() { - return count; + + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return methodName; } + + HttpServletRequest request = attributes.getRequest(); + return methodName + ":" + clientIpResolver.resolve(request); } } - diff --git a/src/main/java/com/carecode/core/client/XmlResponseParser.java b/src/main/java/com/carecode/core/client/XmlResponseParser.java new file mode 100644 index 00000000..639c1429 --- /dev/null +++ b/src/main/java/com/carecode/core/client/XmlResponseParser.java @@ -0,0 +1,93 @@ +package com.carecode.core.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.StringReader; + +/** XML 응답을 JsonNode 로 변환한다. */ +@Component +public class XmlResponseParser { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** XML 문자열을 JsonNode 트리로 변환한다. */ + public JsonNode parse(String xml) { + if (xml == null || xml.isBlank()) { + return null; + } + try { + Document document = newSecureBuilder().parse(new InputSource(new StringReader(xml))); + document.getDocumentElement().normalize(); + return toNode(document.getDocumentElement()); + } catch (Exception e) { + return null; + } + } + + /** 외부 엔티티 참조(XXE)를 차단한 DocumentBuilder. */ + private DocumentBuilder newSecureBuilder() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder(); + } + + private JsonNode toNode(Element element) { + NodeList children = element.getChildNodes(); + + boolean hasElementChild = false; + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + hasElementChild = true; + break; + } + } + + // 자식 엘리먼트가 없으면 텍스트 값 노드 + if (!hasElementChild) { + String text = element.getTextContent(); + return objectMapper.getNodeFactory().textNode(text != null ? text.trim() : ""); + } + + ObjectNode node = objectMapper.createObjectNode(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + Element childElement = (Element) child; + String name = childElement.getNodeName(); + JsonNode childNode = toNode(childElement); + + JsonNode existing = node.get(name); + if (existing == null) { + node.set(name, childNode); + } else if (existing.isArray()) { + // 같은 이름이 반복되면 배열로 모은다 (......) + ((ArrayNode) existing).add(childNode); + } else { + ArrayNode array = objectMapper.createArrayNode(); + array.add(existing); + array.add(childNode); + node.set(name, array); + } + } + return node; + } +} diff --git a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java index 883d4d50..56bc0129 100644 --- a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java +++ b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java @@ -1,82 +1,55 @@ package com.carecode.core.client.constants; -/** - * 공공데이터 API 관련 상수 - * API 엔드포인트, 파라미터명, 응답 코드 등을 상수로 관리 - */ -public class PublicDataApiConstants { +import java.util.Map; - // API 응답 코드 - public static final String SUCCESS_CODE = "00"; - public static final String ERROR_CODE = "99"; +/** 공공데이터 API 공통 상수. */ +public final class PublicDataApiConstants { - // API 파라미터명 - public static final String PARAM_SERVICE_KEY = "serviceKey"; - public static final String PARAM_PAGE_NO = "pageNo"; - public static final String PARAM_NUM_OF_ROWS = "numOfRows"; - public static final String PARAM_TYPE = "type"; - public static final String PARAM_SIDO_CD = "sidoCd"; - public static final String PARAM_POLICY_TYPE = "policyType"; - public static final String PARAM_EDUCATION_TYPE = "educationType"; + /** 공공데이터포털 공통 응답 코드. */ + public static final String RESULT_CODE_SUCCESS = "00"; - // API 응답 타입 - public static final String RESPONSE_TYPE_JSON = "json"; - public static final String RESPONSE_TYPE_XML = "xml"; - - // 육아 관련 API 엔드포인트 - public static final String ENDPOINT_CHILDCARE_FACILITIES = "/getChildcareFacilities"; - public static final String ENDPOINT_CHILDCARE_POLICIES = "/getChildcarePolicies"; - public static final String ENDPOINT_PEDIATRIC_HOSPITALS = "/getPediatricHospitals"; - public static final String ENDPOINT_CHILDCARE_SUBSIDIES = "/getChildcareSubsidies"; - public static final String ENDPOINT_CHILDCARE_EDUCATION = "/getChildcareEducation"; - - // 지역 코드 - public static final String REGION_SEOUL = "11"; - public static final String REGION_BUSAN = "21"; - public static final String REGION_DAEGU = "22"; - public static final String REGION_INCHEON = "23"; - public static final String REGION_GWANGJU = "24"; - public static final String REGION_DAEJEON = "25"; - public static final String REGION_ULSAN = "26"; - public static final String REGION_SEJONG = "29"; - public static final String REGION_GYEONGGI = "31"; - public static final String REGION_GANGWON = "32"; - public static final String REGION_CHUNGBUK = "33"; - public static final String REGION_CHUNGNAM = "34"; - public static final String REGION_JEONBUK = "35"; - public static final String REGION_JEONNAM = "36"; - public static final String REGION_GYEONGBUK = "37"; - public static final String REGION_GYEONGNAM = "38"; - public static final String REGION_JEJU = "39"; - - // 정책 유형 - public static final String POLICY_TYPE_SUBSIDY = "subsidy"; - public static final String POLICY_TYPE_FACILITY = "facility"; - public static final String POLICY_TYPE_EDUCATION = "education"; - public static final String POLICY_TYPE_MEDICAL = "medical"; - - // 교육 유형 - public static final String EDUCATION_TYPE_PARENTING = "parenting"; - public static final String EDUCATION_TYPE_CHILDCARE = "childcare"; - public static final String EDUCATION_TYPE_SAFETY = "safety"; - public static final String EDUCATION_TYPE_HEALTH = "health"; - - // 기본 페이지 설정 - public static final int DEFAULT_PAGE_NO = 1; - public static final int DEFAULT_NUM_OF_ROWS = 10; + /** 한 번에 요청할 수 있는 최대 건수 (서울 열린데이터광장 기준). */ public static final int MAX_NUM_OF_ROWS = 1000; - // 타임아웃 설정 (밀리초) - public static final int CONNECT_TIMEOUT = 10000; - public static final int READ_TIMEOUT = 30000; - - // 에러 메시지 - public static final String ERROR_MSG_API_CALL_FAILED = "API 호출에 실패했습니다."; - public static final String ERROR_MSG_INVALID_RESPONSE = "잘못된 응답입니다."; - public static final String ERROR_MSG_TIMEOUT = "요청 시간이 초과되었습니다."; - public static final String ERROR_MSG_NETWORK_ERROR = "네트워크 오류가 발생했습니다."; + /** 시도명 → 법정동 시도 코드. */ + public static final Map SIDO_CODES = Map.ofEntries( + Map.entry("서울특별시", "11"), + Map.entry("부산광역시", "21"), + Map.entry("대구광역시", "22"), + Map.entry("인천광역시", "23"), + Map.entry("광주광역시", "24"), + Map.entry("대전광역시", "25"), + Map.entry("울산광역시", "26"), + Map.entry("세종특별자치시", "29"), + Map.entry("경기도", "31"), + Map.entry("강원특별자치도", "32"), + Map.entry("충청북도", "33"), + Map.entry("충청남도", "34"), + Map.entry("전북특별자치도", "35"), + Map.entry("전라남도", "36"), + Map.entry("경상북도", "37"), + Map.entry("경상남도", "38"), + Map.entry("제주특별자치도", "39")); private PublicDataApiConstants() { // 유틸리티 클래스이므로 인스턴스화 방지 } -} \ No newline at end of file + + /** 시도명으로 시도 코드를 찾는다. */ + public static String findSidoCode(String sidoName) { + if (sidoName == null || sidoName.isBlank()) { + return null; + } + String normalized = sidoName.trim(); + + String exact = SIDO_CODES.get(normalized); + if (exact != null) { + return exact; + } + return SIDO_CODES.entrySet().stream() + .filter(e -> e.getKey().startsWith(normalized) || normalized.startsWith(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java new file mode 100644 index 00000000..39ff9203 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -0,0 +1,99 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** 공공데이터포털(data.go.kr) 공급자. */ +@Slf4j +@Component +public class DataGoKrProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "DATA_GO_KR"; + + private final RestTemplate restTemplate; + private final String serviceKey; + private final String baseUrl; + + public DataGoKrProvider(RestTemplate restTemplate, + @Value("${public.data.datagokr.service-key:}") String serviceKey, + @Value("${public.data.datagokr.base-url:https://apis.data.go.kr}") String baseUrl) { + this.restTemplate = restTemplate; + this.serviceKey = serviceKey; + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return serviceKey != null && !serviceKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("공공데이터포털 서비스 키가 설정되지 않았습니다."); + } + + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(toAbsoluteUrl(resource)) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", numOfRows); + + // 응답 포맷 파라미터는 데이터셋마다 이름이 다르다(type / _type). + // 호출부가 지정하지 않았을 때만 기본값을 넣는다 — 지정했는데 또 넣으면 파라미터가 중복된다. + boolean formatSpecified = params != null + && (params.containsKey("type") || params.containsKey("_type")); + if (!formatSpecified) { + builder.queryParam("type", "json"); + } + + if (params != null) { + params.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + builder.queryParam(k, v); + } + }); + } + + // serviceKey 는 이미 인코딩된 값이므로 빌더를 거치지 않고 직접 이어 붙인다. + String encoded = builder.encode(StandardCharsets.UTF_8).toUriString(); + String url = encoded + "&serviceKey=" + serviceKey; + + log.debug("공공데이터포털 호출: resource={}, page={}, rows={}", resource, pageNo, numOfRows); + + try { + return restTemplate.getForObject(URI.create(url), String.class); + } catch (Exception e) { + throw new PublicDataApiException( + "공공데이터포털 호출 실패: resource=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + /** 표준데이터처럼 다른 호스트에 있는 데이터셋은 절대 URL 로 지정할 수 있게 한다. */ + private String toAbsoluteUrl(String resource) { + if (resource != null && (resource.startsWith("http://") || resource.startsWith("https://"))) { + return resource; + } + return baseUrl + "/" + stripLeadingSlash(resource); + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private String stripLeadingSlash(String path) { + return path != null && path.startsWith("/") ? path.substring(1) : path; + } +} diff --git a/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java new file mode 100644 index 00000000..00c4b669 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java @@ -0,0 +1,15 @@ +package com.carecode.core.client.provider; + +import java.util.Map; + +/** 공공데이터 공급자 추상화. 공급자별 인증 방식과 URL 규격 차이를 구현체가 흡수한다. */ +public interface PublicDataProvider { + + String getProviderName(); + + /** API 키가 없으면 false. 호출부가 조용히 건너뛴다. */ + boolean isAvailable(); + + /** 원본 응답 본문을 반환한다. pageNo는 1부터 시작. */ + String fetch(String resource, int pageNo, int numOfRows, Map params); +} diff --git a/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java new file mode 100644 index 00000000..57a038c2 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java @@ -0,0 +1,67 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** 서울 열린데이터광장 공급자. 페이지 번호 대신 시작·종료 인덱스를 URL 경로에 넣는다. */ +@Slf4j +@Component +public class SeoulOpenDataProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "SEOUL_OPEN_DATA"; + + private static final int MAX_ROWS = 1000; + + private final RestTemplate restTemplate; + private final String apiKey; + private final String baseUrl; + + public SeoulOpenDataProvider(RestTemplate restTemplate, + @Value("${public.data.seoul.api-key:${public.data.api.key:}}") String apiKey, + @Value("${public.data.seoul.base-url:http://openapi.seoul.go.kr:8088}") String baseUrl) { + this.restTemplate = restTemplate; + this.apiKey = apiKey; + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("서울 열린데이터광장 API 키가 설정되지 않았습니다."); + } + + int rows = Math.min(numOfRows, MAX_ROWS); + int startIndex = (pageNo - 1) * rows + 1; + int endIndex = pageNo * rows; + + String url = String.format("%s/%s/json/%s/%d/%d/", baseUrl, apiKey, resource, startIndex, endIndex); + log.debug("서울 열린데이터광장 호출: service={}, {}~{}", resource, startIndex, endIndex); + + try { + return restTemplate.getForObject(url, String.class); + } catch (Exception e) { + // API 키가 URL 경로에 들어가므로 예외 메시지에 URL 을 넣지 않는다. + throw new PublicDataApiException( + "서울 열린데이터광장 호출 실패: service=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java new file mode 100644 index 00000000..bccd1766 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java @@ -0,0 +1,131 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** 시설 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CareFacilityUpsertService { + + private final CareFacilityRepository careFacilityRepository; + + /** 시설 코드 기준 upsert. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row) { + String facilityCode = text(row, "STCODE", "crcodeCd", "crcode"); + if (facilityCode == null) { + throw new IllegalArgumentException("시설 코드가 없는 응답입니다."); + } + + CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); + boolean isNew = facility == null; + if (isNew) { + facility = CareFacility.builder() + .facilityCode(facilityCode) + .isActive(true) + .viewCount(0) + .build(); + } + + applyIfPresent(text(row, "CRNAME", "crname"), facility::setName); + applyIfPresent(text(row, "CRADDR", "craddr"), facility::setAddress); + applyIfPresent(text(row, "CRTELNO", "crtelno"), facility::setPhone); + applyIfPresent(text(row, "CRHOME", "crhome"), facility::setWebsite); + + String typeName = text(row, "CRTYPENAME", "crtypeName"); + if (typeName != null) { + facility.setFacilityType(resolveType(typeName)); + } else if (isNew) { + facility.setFacilityType(FacilityType.DAYCARE); + } + + Integer capacity = integer(row, "CRCAPAT", "crcapat"); + if (capacity != null) { + facility.setCapacity(capacity); + } + Integer enrollment = integer(row, "CRCHCNT", "crchcnt"); + if (enrollment != null) { + facility.setCurrentEnrollment(enrollment); + Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); + if (effectiveCapacity != null) { + facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); + } + } + + Double lat = decimal(row, "LA", "la", "LAT"); + Double lng = decimal(row, "LO", "lo", "LNG"); + if (lat != null && lng != null) { + facility.setLatitude(lat); + facility.setLongitude(lng); + } + + facility.setUpdatedAt(LocalDateTime.now()); + careFacilityRepository.save(facility); + return isNew; + } + + private void applyIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** 응답 필드명이 대문자/카멜케이스로 섞여 오는 경우가 있어 후보를 순서대로 본다. */ + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Integer integer(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value.replaceAll("[^0-9-]", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + + private FacilityType resolveType(String typeName) { + if (typeName.contains("유치원")) { + return FacilityType.KINDERGARTEN; + } + if (typeName.contains("어린이집")) { + return FacilityType.DAYCARE; + } + return FacilityType.OTHER; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java new file mode 100644 index 00000000..f68e6ab6 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java @@ -0,0 +1,64 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** 정부 지원 서비스(보조금24) 동기화. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GovernmentBenefitSyncService { + + private static final int ROWS_PER_PAGE = 100; + + /** 육아 관련 서비스만 적재하기 위한 키워드. 하나라도 포함되면 대상으로 본다. */ + private static final List CARE_KEYWORDS = List.of( + "육아", "출산", "임신", "보육", "양육", "아동", "어린이", "영유아", + "유아", "산모", "신생아", "돌봄", "child", "어린이집", "유치원"); + + private final DataGoKrProvider provider; + private final PolicyUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + @Value("${public.data.resource.benefit:1741000/publicServiceInformations/publicServiceInformation}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("정부지원서비스") + .rowsPerPage(ROWS_PER_PAGE) + .filter(this::isCareRelated) + .upsert(upsertService::upsert) + .build()); + } + + /** 서비스명·요약·분야 중 하나라도 육아 키워드를 포함하면 적재 대상. */ + private boolean isCareRelated(JsonNode row) { + String haystack = String.join(" ", + text(row, "서비스명", "servNm", "SVC_NM"), + text(row, "서비스목적요약", "servDgst", "SVC_DGST"), + text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM"), + text(row, "지원대상", "trgterIndvdlArray")) + .toLowerCase(); + + return CARE_KEYWORDS.stream().anyMatch(k -> haystack.contains(k.toLowerCase())); + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + return node.asText().trim(); + } + } + return ""; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java new file mode 100644 index 00000000..cf4cf97f --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java @@ -0,0 +1,90 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.health.entity.Hospital; +import com.carecode.domain.health.repository.HospitalRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 병원 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HospitalUpsertService { + + private final HospitalRepository hospitalRepository; + + /** 요양기호(ykiho) 기준 upsert. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row, String defaultType) { + String ykiho = text(row, "ykiho", "YKIHO"); + if (ykiho == null) { + throw new IllegalArgumentException("요양기호(ykiho)가 없는 응답입니다."); + } + + Hospital hospital = hospitalRepository.findByExternalCode(ykiho).orElse(null); + boolean isNew = hospital == null; + if (isNew) { + hospital = Hospital.builder().externalCode(ykiho).build(); + } + + String name = text(row, "yadmNm", "YADMNM"); + if (name != null) { + hospital.setName(name); + } else if (isNew) { + // name 은 NOT NULL 이라 값이 없으면 저장할 수 없다. + throw new IllegalArgumentException("요양기관명이 없는 응답입니다: ykiho=" + ykiho); + } + + applyIfPresent(text(row, "addr", "ADDR"), hospital::setAddress); + applyIfPresent(text(row, "telno", "TELNO"), hospital::setPhone); + + String clCdNm = text(row, "clCdNm", "CLCDNM"); + hospital.setType(clCdNm != null ? clCdNm : defaultType); + + // 심평원 좌표는 XPos=경도, YPos=위도 순서다. 뒤집으면 지도에서 엉뚱한 위치가 나온다. + Double lng = decimal(row, "XPos", "XPOS"); + Double lat = decimal(row, "YPos", "YPOS"); + if (lat != null && lng != null) { + hospital.setLatitude(lat); + hospital.setLongitude(lng); + } + + hospitalRepository.save(hospital); + return isNew; + } + + private void applyIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java new file mode 100644 index 00000000..502b8a92 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java @@ -0,0 +1,35 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** 전국 유치원 표준데이터 동기화. 어린이집만 있고 유치원이 비어 있던 공백을 메운다. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KindergartenSyncService { + + private static final int ROWS_PER_PAGE = 500; + + private final DataGoKrProvider provider; + private final KindergartenUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + /** 표준데이터는 apis.data.go.kr 이 아닌 별도 호스트라 절대 URL 로 지정한다. */ + @Value("${public.data.resource.kindergarten:" + + "http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("전국유치원") + .rowsPerPage(ROWS_PER_PAGE) + .upsert(upsertService::upsert) + .build()); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java new file mode 100644 index 00000000..3c966788 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java @@ -0,0 +1,155 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.function.Consumer; + +/** 유치원 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KindergartenUpsertService { + + /** 어린이집 시설 코드와 충돌하지 않도록 구분한다. */ + public static final String CODE_PREFIX = "KG-"; + + private final CareFacilityRepository careFacilityRepository; + + /** 시설 코드 기준 upsert. 표준데이터에 고유 코드가 없으면 이름+주소로 만들어 쓴다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row) { + String name = text(row, "유치원명", "kindrgrtnNm", "KINDER_NM"); + String address = text(row, "소재지도로명주소", "rdnmadr", "소재지지번주소", "lnmadr", "ADDR"); + if (name == null) { + throw new IllegalArgumentException("유치원명이 없는 응답입니다."); + } + + String facilityCode = resolveCode(row, name, address); + CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); + boolean isNew = facility == null; + if (isNew) { + facility = CareFacility.builder() + .facilityCode(facilityCode) + .facilityType(FacilityType.KINDERGARTEN) + .isActive(true) + .viewCount(0) + .build(); + } + + facility.setName(name); + applyIfPresent(address, facility::setAddress); + applyIfPresent(text(row, "전화번호", "telno", "TEL"), facility::setPhone); + applyIfPresent(text(row, "홈페이지주소", "homepageAddr", "HOMEPAGE"), facility::setWebsite); + applyIfPresent(text(row, "운영시간", "operPdCn"), facility::setOperatingHours); + applyIfPresent(text(row, "시도명", "ctprvnNm"), facility::setCity); + applyIfPresent(text(row, "시군구명", "signguNm"), facility::setDistrict); + + // 국공립 여부는 설립유형에서 판단한다. 사립은 비용 부담이 달라 사용자에게 중요한 구분이다. + String establishment = text(row, "설립유형", "estblshSe", "설립구분"); + if (establishment != null) { + facility.setIsPublic(establishment.contains("공립") || establishment.contains("국립")); + } + + Integer capacity = integer(row, "정원", "fixnum", "TOTAL_CAPACITY"); + if (capacity != null) { + facility.setCapacity(capacity); + } + Integer enrollment = integer(row, "현원", "nowNmpr", "CURRENT_CNT"); + if (enrollment != null) { + facility.setCurrentEnrollment(enrollment); + Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); + if (effectiveCapacity != null) { + facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); + } + } + + Double lat = decimal(row, "위도", "latitude", "LAT"); + Double lng = decimal(row, "경도", "longitude", "LNG"); + if (lat != null && lng != null) { + facility.setLatitude(lat); + facility.setLongitude(lng); + } + + facility.setUpdatedAt(LocalDateTime.now()); + careFacilityRepository.save(facility); + return isNew; + } + + /** + * 표준데이터는 고유 식별자를 주지 않는 경우가 많다. + * 이름+주소 해시를 코드로 쓰면 재동기화 때 같은 유치원이 중복 생성되지 않는다. + */ + private String resolveCode(JsonNode row, String name, String address) { + String external = text(row, "유치원코드", "kindrgrtnCode", "KINDER_CD"); + if (external != null) { + return CODE_PREFIX + external; + } + String naturalKey = name + "|" + (address != null ? address : ""); + return CODE_PREFIX + hash(naturalKey); + } + + private String hash(String value) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest, 0, 12); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 을 사용할 수 없습니다.", e); + } + } + + private void applyIfPresent(String value, Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** 표준데이터는 한글 필드명, 오픈API 는 영문 필드명을 쓰므로 후보를 순서대로 본다. */ + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Integer integer(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value.replaceAll("[^0-9-]", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java new file mode 100644 index 00000000..cf49d9d8 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -0,0 +1,34 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** 전국 어린이집 정보 동기화. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NationwideChildcareFacilitySyncService { + + private static final int ROWS_PER_PAGE = 500; + + private final DataGoKrProvider provider; + private final CareFacilityUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + /** 데이터셋 경로는 개편될 수 있어 재배포 없이 바꿀 수 있게 프로퍼티로 둔다. */ + @Value("${public.data.resource.childcare:B551014/CCEF/childcare}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("전국어린이집") + .rowsPerPage(ROWS_PER_PAGE) + .upsert(upsertService::upsert) + .build()); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java b/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java new file mode 100644 index 00000000..5df96fad --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java @@ -0,0 +1,160 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** 공공데이터 페이징 수집 공통 절차. 데이터셋별 차이는 SyncSpec 으로 받는다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PagedSyncTemplate { + + private final ObjectMapper objectMapper; + private final XmlResponseParser xmlResponseParser; + + /** 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다 — 조용한 중단은 하지 않는다. */ + @Value("${public.data.sync.max-pages:200}") + private int maxPages; + + public SyncResult run(SyncSpec spec) { + SyncResult result = new SyncResult(spec.getProvider().getProviderName(), spec.getLabel()); + + if (!spec.getProvider().isAvailable()) { + result.stop("서비스 키 미설정"); + log.info("{} 동기화 건너뜀 - 서비스 키가 없습니다.", spec.getLabel()); + return result; + } + + for (int page = 1; page <= maxPages; page++) { + JsonNode rows; + try { + String body = spec.getProvider() + .fetch(spec.getResource(), page, spec.getRowsPerPage(), spec.getParams()); + rows = extractRows(body); + } catch (Exception e) { + log.error("{} 조회 실패 - page={}", spec.getLabel(), page, e); + result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); + return result; + } + + if (rows == null || !rows.isArray() || rows.isEmpty()) { + return result; // 더 이상 데이터 없음 — 정상 종료 + } + if (page == 1) { + logFieldNames(spec.getLabel(), rows.get(0)); + } + + int attempted = 0; + int failedInPage = 0; + for (JsonNode row : rows) { + if (!spec.getFilter().test(row)) { + result.countSkipped(); + continue; + } + attempted++; + try { + if (Boolean.TRUE.equals(spec.getUpsert().apply(row))) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + // 한 건 실패가 배치 전체를 중단시키지 않는다. + result.countFailed(); + failedInPage++; + log.warn("{} 저장 실패: {}", spec.getLabel(), e.getMessage()); + } + } + result.countPage(); + + // 첫 페이지가 전멸이면 응답 스펙이 바뀐 것이다. 200페이지를 헛돌지 않고 멈춘다. + if (page == 1 && attempted > 0 && failedInPage == attempted) { + result.stop("첫 페이지 전건 실패 - 응답 필드 매핑 불일치 의심"); + log.error("{} 동기화 중단 - 첫 페이지 {}건이 모두 실패했습니다. 응답 필드명을 확인하세요.", + spec.getLabel(), attempted); + return result; + } + + if (rows.size() < spec.getRowsPerPage()) { + return result; // 마지막 페이지 + } + } + + result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); + log.warn("{} 동기화가 페이지 상한에 걸렸습니다. public.data.sync.max-pages 설정을 확인하세요.", spec.getLabel()); + return result; + } + + /** 매핑이 어긋났을 때 원인을 바로 찾을 수 있도록 실제 응답 필드명을 남긴다. */ + private void logFieldNames(String label, JsonNode firstRow) { + if (firstRow == null || !log.isDebugEnabled()) { + return; + } + List names = new ArrayList<>(); + firstRow.fieldNames().forEachRemaining(names::add); + log.debug("{} 응답 필드: {}", label, names); + } + + /** 공공데이터 응답은 래핑 구조가 제각각이라 알려진 위치를 순서대로 확인한다. */ + private JsonNode extractRows(String body) { + JsonNode root = parseBody(body); + if (root == null) { + return null; + } + if (root.isArray()) { + return root; + } + + // XML 로 받으면 최상위 response 가 벗겨져 body 부터 시작한다. + for (JsonNode items : List.of( + root.path("response").path("body").path("items"), + root.path("body").path("items"), + root.path("data"), + root.path("items"))) { + JsonNode rows = toArray(items); + if (rows != null) { + return rows; + } + } + return null; + } + + /** items 가 배열이거나, item 을 한 겹 더 감싸거나, 단건이면 객체로 온다. */ + private JsonNode toArray(JsonNode items) { + if (items.isArray()) { + return items; + } + JsonNode item = items.path("item"); + if (item.isArray()) { + return item; + } + if (item.isObject()) { + return objectMapper.createArrayNode().add(item); + } + return null; + } + + /** JSON 을 먼저 시도하고, 아니면 XML 로 파싱한다. 데이터셋마다 기본 포맷이 다르다. */ + private JsonNode parseBody(String body) { + if (body == null || body.isBlank()) { + return null; + } + String trimmed = body.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return objectMapper.readTree(trimmed); + } catch (Exception e) { + log.debug("JSON 파싱 실패, XML 로 재시도합니다: {}", e.getMessage()); + } + } + return xmlResponseParser.parse(trimmed); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java new file mode 100644 index 00000000..851d2278 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java @@ -0,0 +1,52 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 소아청소년과 병원 동기화. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PediatricHospitalSyncService { + + private static final int ROWS_PER_PAGE = 100; + + private final DataGoKrProvider provider; + private final HospitalUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + @Value("${public.data.resource.hospital:B551182/hospInfoServicev2/getHospBasisList}") + private String resource; + + /** 진료과목 코드. */ + @Value("${public.data.hospital.pediatric-subject-code:10}") + private String pediatricSubjectCode; + + @Value("${public.data.hospital.subject-name:소아청소년과}") + private String subjectName; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("소아청소년과병원") + .rowsPerPage(ROWS_PER_PAGE) + .params(buildParams()) + .upsert(row -> upsertService.upsert(row, subjectName)) + .build()); + } + + private Map buildParams() { + Map params = new LinkedHashMap<>(); + params.put("dgsbjtCd", pediatricSubjectCode); + // 이 데이터셋은 XML 이 기본이다. JSON 을 지원하면 _type 으로 받고, 아니면 XML 로 파싱된다. + params.put("_type", "json"); + return params; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java new file mode 100644 index 00000000..2c139869 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -0,0 +1,91 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.util.AgeRangeParser; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** 정책 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyUpsertService { + + /** 공공데이터 출처 정책임을 나타내는 코드 접두어. 관리자 수기 등록 정책과 구분한다. */ + public static final String EXTERNAL_CODE_PREFIX = "GOV-"; + + private final PolicyRepository policyRepository; + + /** 서비스 ID 기준 upsert. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @CacheEvict(cacheNames = "policy", allEntries = true) + public boolean upsert(JsonNode row) { + String serviceId = text(row, "서비스ID", "servId", "SVC_ID"); + if (serviceId == null) { + throw new IllegalArgumentException("서비스 ID가 없는 응답입니다."); + } + + String policyCode = EXTERNAL_CODE_PREFIX + serviceId; + Policy policy = policyRepository.findByPolicyCode(policyCode).orElse(null); + boolean isNew = policy == null; + if (isNew) { + policy = new Policy(); + policy.setPolicyCode(policyCode); + policy.setViewCount(0); + policy.setIsActive(true); + } + + policy.setTitle(text(row, "서비스명", "servNm", "SVC_NM")); + policy.setDescription(text(row, "서비스목적요약", "servDgst", "SVC_DGST")); + policy.setPolicyType(text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM")); + policy.setTargetRegion(text(row, "소관기관명", "jurMnofNm", "JURISDICTION")); + policy.setApplicationUrl(text(row, "상세조회URL", "servDtlLink", "DETAIL_URL")); + policy.setContactInfo(text(row, "전화문의", "rprsCtadr", "CONTACT")); + policy.setRequiredDocuments(text(row, "구비서류", "docCn")); + applyAgeRange(policy, row); + policy.setUpdatedAt(LocalDateTime.now()); + + policyRepository.save(policy); + return isNew; + } + + /** 자유 텍스트에서 연령 조건을 개월로 환산해 채운다. 못 찾으면 기존 값을 건드리지 않는다. */ + private void applyAgeRange(Policy policy, JsonNode row) { + String source = String.join(" ", + nullToEmpty(text(row, "지원대상", "trgterIndvdlArray")), + nullToEmpty(text(row, "선정기준", "slctCritCn")), + nullToEmpty(text(row, "서비스목적요약", "servDgst", "SVC_DGST"))); + + AgeRangeParser.AgeRange range = AgeRangeParser.parse(source); + if (range == null) { + return; + } + policy.setTargetAgeMin(range.minMonths()); + policy.setTargetAgeMax(range.maxMonths()); + } + + private String nullToEmpty(String value) { + return value != null ? value : ""; + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/SyncResult.java b/src/main/java/com/carecode/core/client/sync/SyncResult.java new file mode 100644 index 00000000..ec886390 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/SyncResult.java @@ -0,0 +1,65 @@ +package com.carecode.core.client.sync; + +import lombok.Getter; + +/** 공공데이터 동기화 결과. */ +@Getter +public class SyncResult { + + private final String provider; + private final String resource; + private int created; + private int updated; + private int failed; + + /** 필터에 걸려 적재하지 않은 건수. 실패와 구분해야 필터가 과도한지 알 수 있다. */ + private int skipped; + private int pagesProcessed; + + /** 정상 완료가 아니면 중단 사유. 정상이면 null. */ + private String stoppedReason; + + public SyncResult(String provider, String resource) { + this.provider = provider; + this.resource = resource; + } + + public void countCreated() { + created++; + } + + public void countUpdated() { + updated++; + } + + public void countFailed() { + failed++; + } + + public void countSkipped() { + skipped++; + } + + public void countPage() { + pagesProcessed++; + } + + public void stop(String reason) { + this.stoppedReason = reason; + } + + public boolean isCompleted() { + return stoppedReason == null; + } + + public int getTotalProcessed() { + return created + updated; + } + + @Override + public String toString() { + return String.format("[%s/%s] 신규=%d, 갱신=%d, 실패=%d, 제외=%d, 페이지=%d%s", + provider, resource, created, updated, failed, skipped, pagesProcessed, + stoppedReason != null ? ", 중단사유=" + stoppedReason : ""); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/SyncSpec.java b/src/main/java/com/carecode/core/client/sync/SyncSpec.java new file mode 100644 index 00000000..339c4469 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/SyncSpec.java @@ -0,0 +1,36 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.PublicDataProvider; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +/** 페이징 동기화 한 건의 명세. 데이터셋마다 다른 부분만 여기에 담는다. */ +@Getter +@Builder +public class SyncSpec { + + private final PublicDataProvider provider; + + /** 공급자 기준 상대 경로 또는 절대 URL. */ + private final String resource; + + /** 로그·응답에 쓰는 사람이 읽는 이름. */ + private final String label; + + private final int rowsPerPage; + + @Builder.Default + private final Map params = Map.of(); + + /** 적재 대상이 아닌 행을 걸러낸다. 기본은 전부 적재. */ + @Builder.Default + private final Predicate filter = row -> true; + + /** 한 건 저장. 신규면 true, 갱신이면 false 를 반환한다. */ + private final Function upsert; +} diff --git a/src/main/java/com/carecode/core/config/AsyncConfig.java b/src/main/java/com/carecode/core/config/AsyncConfig.java new file mode 100644 index 00000000..4a654109 --- /dev/null +++ b/src/main/java/com/carecode/core/config/AsyncConfig.java @@ -0,0 +1,38 @@ +package com.carecode.core.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 비동기 실행 설정. + * + *

알림 발송(SMTP/FCM 왕복)이 요청 스레드를 붙잡지 않도록 별도 풀에서 처리한다. + */ +@Slf4j +@Configuration +@EnableAsync +@EnableScheduling +public class AsyncConfig { + + @Bean(name = "notificationExecutor") + public Executor notificationExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("notify-"); + // 큐가 가득 차면 호출 스레드에서 실행한다. 알림을 조용히 버리는 것보다 낫다. + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(20); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/carecode/core/config/CacheConfig.java b/src/main/java/com/carecode/core/config/CacheConfig.java index 1252a75d..190d4f06 100644 --- a/src/main/java/com/carecode/core/config/CacheConfig.java +++ b/src/main/java/com/carecode/core/config/CacheConfig.java @@ -1,5 +1,10 @@ package com.carecode.core.config; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -32,10 +37,30 @@ private RedisCacheConfiguration defaultCacheConfig() { .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair - .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .fromSerializer(new GenericJackson2JsonRedisSerializer(cacheObjectMapper()))) .disableCachingNullValues(); // null 값은 캐싱하지 않음 } + /** + * 캐시 값 직렬화용 ObjectMapper. + * JavaTimeModule 이 없으면 LocalDate/LocalDateTime 필드를 가진 DTO 캐싱이 실패한다. + */ + private ObjectMapper cacheObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + // 역직렬화 시 구체 타입을 복원하기 위한 타입 정보 포함 + mapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.carecode.") + .allowIfSubType("java.util.") + .allowIfSubType("java.time.") + .build(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY); + return mapper; + } + // 캐시별 TTL 설정 diff --git a/src/main/java/com/carecode/core/config/FirebaseConfig.java b/src/main/java/com/carecode/core/config/FirebaseConfig.java new file mode 100644 index 00000000..b3e0ddd4 --- /dev/null +++ b/src/main/java/com/carecode/core/config/FirebaseConfig.java @@ -0,0 +1,63 @@ +package com.carecode.core.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import java.io.InputStream; + +/** + * FCM 초기화. + * + *

서비스 계정 자격증명이 설정되지 않은 환경(로컬/CI)에서도 애플리케이션이 뜨도록 + * 자격증명이 없으면 {@link FirebaseMessaging} 빈을 만들지 않는다. + * 푸시 발송기는 이 빈이 없으면 자동으로 비활성 상태가 된다. + */ +@Slf4j +@Configuration +public class FirebaseConfig { + + @Bean + public FirebaseMessaging firebaseMessaging( + ResourceLoader resourceLoader, + @Value("${app.notification.fcm.credentials-location:}") String credentialsLocation) { + + if (credentialsLocation == null || credentialsLocation.isBlank()) { + log.info("FCM 자격증명이 설정되지 않아 푸시 알림이 비활성화됩니다. " + + "(app.notification.fcm.credentials-location)"); + return null; + } + + try { + Resource resource = resourceLoader.getResource(credentialsLocation); + if (!resource.exists()) { + log.warn("FCM 자격증명 파일을 찾을 수 없어 푸시 알림이 비활성화됩니다: {}", credentialsLocation); + return null; + } + + try (InputStream credentialStream = resource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(credentialStream)) + .build(); + + FirebaseApp app = FirebaseApp.getApps().isEmpty() + ? FirebaseApp.initializeApp(options) + : FirebaseApp.getInstance(); + + log.info("FCM 초기화 완료"); + return FirebaseMessaging.getInstance(app); + } + } catch (Exception e) { + // 푸시 설정 실패가 서비스 전체 기동을 막지 않도록 한다. + log.error("FCM 초기화 실패 - 푸시 알림이 비활성화됩니다", e); + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/config/QueryCountConfig.java b/src/main/java/com/carecode/core/config/QueryCountConfig.java new file mode 100644 index 00000000..2049b476 --- /dev/null +++ b/src/main/java/com/carecode/core/config/QueryCountConfig.java @@ -0,0 +1,42 @@ +package com.carecode.core.config; + +import com.carecode.core.monitoring.QueryCountFilter; +import com.carecode.core.monitoring.QueryCountInspector; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.Map; + +/** 쿼리 수 측정. 기본은 꺼져 있고 app.monitoring.query-count.enabled=true 일 때만 동작한다. */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "app.monitoring.query-count.enabled", havingValue = "true") +public class QueryCountConfig { + + @Value("${app.monitoring.query-count.threshold:20}") + private int threshold; + + /** StatementInspector 는 SessionFactory 생성 시점에 주입해야 하므로 프로퍼티로 넣는다. */ + @Bean + public HibernatePropertiesCustomizer queryCountInspectorCustomizer() { + log.info("쿼리 수 측정 활성화 - 임계치 {}건", threshold); + return (Map props) -> + props.put("hibernate.session_factory.statement_inspector", new QueryCountInspector()); + } + + /** 가장 바깥에서 감싸야 요청 전체의 쿼리를 센다. */ + @Bean + public FilterRegistrationBean queryCountFilterRegistration() { + FilterRegistrationBean registration = + new FilterRegistrationBean<>(new QueryCountFilter(threshold)); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); + registration.addUrlPatterns("/*"); + return registration; + } +} diff --git a/src/main/java/com/carecode/core/config/WebMvcConfig.java b/src/main/java/com/carecode/core/config/WebMvcConfig.java new file mode 100644 index 00000000..342896ac --- /dev/null +++ b/src/main/java/com/carecode/core/config/WebMvcConfig.java @@ -0,0 +1,61 @@ +package com.carecode.core.config; + +import com.carecode.core.RateLimitInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Paths; + +/** + * 웹 계층 공통 설정. + * + *

{@link RateLimitInterceptor} 는 {@code @Component} 로 빈 등록만 되어 있고 + * 인터셉터 체인에는 연결되어 있지 않아 동작하지 않는 상태였다. 여기서 등록한다. + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + @Value("${app.storage.local.root:./uploads}") + private String storageRoot; + + @Value("${app.storage.public-base-url:/files}") + private String publicBaseUrl; + + /** + * 업로드된 파일을 정적 리소스로 서빙한다. + * (S3 로 전환하면 이 매핑 대신 버킷 URL 을 그대로 쓰면 된다.) + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String location = Paths.get(storageRoot).toAbsolutePath().normalize().toUri().toString(); + registry.addResourceHandler(publicBaseUrl + "/**") + .addResourceLocations(location) + .setCachePeriod(3600); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/actuator/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/api-docs/**", + "/v3/api-docs/**", + "/error", + "/favicon.ico", + "/css/**", + "/js/**", + "/images/**", + "/static/**" + ); + } +} diff --git a/src/main/java/com/carecode/core/controller/CareFacilityApiController.java b/src/main/java/com/carecode/core/controller/CareFacilityApiController.java index 910d8e88..039e1dda 100644 --- a/src/main/java/com/carecode/core/controller/CareFacilityApiController.java +++ b/src/main/java/com/carecode/core/controller/CareFacilityApiController.java @@ -5,6 +5,7 @@ import com.carecode.domain.careFacility.dto.response.CareFacilityStatsResponse; import com.carecode.domain.careFacility.entity.FacilityType; import com.carecode.domain.careFacility.service.CareFacilityService; +import com.carecode.core.util.PageRequestUtil; import com.carecode.domain.careFacility.entity.CareFacility; import com.carecode.domain.careFacility.repository.CareFacilityRepository; import io.swagger.v3.oas.annotations.Operation; @@ -179,20 +180,18 @@ public ResponseEntity> swaggerGetDbFacilities( try { log.info("DB 저장된 보육시설 목록 조회 요청: page={}, size={}", page, size); - // CareFacilityService를 통해 DB에서 데이터 조회 - List facilities = careFacilityService.getAllCareFacilities(); - - // 페이징 처리 - int start = page * size; - int end = Math.min(start + size, facilities.size()); - List pagedFacilities = facilities.subList(start, end); - + // 전체를 읽어 subList 로 자르면 테이블이 커질수록 그대로 부하가 되고, + // start 가 목록 크기를 넘으면 IndexOutOfBoundsException 이 난다. DB 페이징으로 처리한다. + 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", facilities.size(), - "currentPage", page, - "pageSize", size, + "totalCount", careFacilityService.countCareFacilities(), + "currentPage", safePage, + "pageSize", safeSize, "data", pagedFacilities )); diff --git a/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java b/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java new file mode 100644 index 00000000..2c3a2121 --- /dev/null +++ b/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java @@ -0,0 +1,15 @@ +package com.carecode.core.exception; + +/** + * 댓글에 대한 접근(수정/삭제) 권한이 없을 때 발생하는 예외 + */ +public class CommentAccessDeniedException extends CareCodeException { + + public CommentAccessDeniedException() { + super(ErrorCode.COMMENT_ACCESS_DENIED); + } + + public CommentAccessDeniedException(String message) { + super(ErrorCode.COMMENT_ACCESS_DENIED, message); + } +} diff --git a/src/main/java/com/carecode/core/exception/ErrorCode.java b/src/main/java/com/carecode/core/exception/ErrorCode.java index 3958c6f9..10f9c2aa 100644 --- a/src/main/java/com/carecode/core/exception/ErrorCode.java +++ b/src/main/java/com/carecode/core/exception/ErrorCode.java @@ -16,6 +16,7 @@ public enum ErrorCode { UNAUTHORIZED("C002", "인증이 필요합니다", HttpStatus.UNAUTHORIZED), FORBIDDEN("C003", "접근 권한이 없습니다", HttpStatus.FORBIDDEN), RESOURCE_NOT_FOUND("C004", "요청한 리소스를 찾을 수 없습니다", HttpStatus.NOT_FOUND), + RATE_LIMIT_EXCEEDED("C005", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요", HttpStatus.TOO_MANY_REQUESTS), // ===== 사용자 관련 에러 (U000) ===== USER_NOT_FOUND("U001", "사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND), diff --git a/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java b/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java new file mode 100644 index 00000000..52eb9ee8 --- /dev/null +++ b/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java @@ -0,0 +1,15 @@ +package com.carecode.core.exception; + +/** + * 게시글에 대한 접근(수정/삭제) 권한이 없을 때 발생하는 예외 + */ +public class PostAccessDeniedException extends CareCodeException { + + public PostAccessDeniedException() { + super(ErrorCode.POST_ACCESS_DENIED); + } + + public PostAccessDeniedException(String message) { + super(ErrorCode.POST_ACCESS_DENIED, message); + } +} diff --git a/src/main/java/com/carecode/core/exception/RateLimitExceededException.java b/src/main/java/com/carecode/core/exception/RateLimitExceededException.java new file mode 100644 index 00000000..fc11e0a9 --- /dev/null +++ b/src/main/java/com/carecode/core/exception/RateLimitExceededException.java @@ -0,0 +1,15 @@ +package com.carecode.core.exception; + +/** + * Rate limit 초과 시 발생하는 예외 (HTTP 429). + */ +public class RateLimitExceededException extends CareCodeException { + + public RateLimitExceededException() { + super(ErrorCode.RATE_LIMIT_EXCEEDED); + } + + public RateLimitExceededException(String message) { + super(ErrorCode.RATE_LIMIT_EXCEEDED, message); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java b/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java new file mode 100644 index 00000000..dca1734c --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java @@ -0,0 +1,46 @@ +package com.carecode.core.monitoring; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** 요청별 쿼리 수를 집계해 임계치를 넘으면 경고한다. 어느 엔드포인트가 N+1 인지 로그로 드러난다. */ +@Slf4j +@RequiredArgsConstructor +public class QueryCountFilter extends OncePerRequestFilter { + + private final int threshold; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + QueryCountHolder.start(); + long startedAt = System.nanoTime(); + try { + chain.doFilter(request, response); + } finally { + int queryCount = QueryCountHolder.get(); + QueryCountHolder.clear(); + + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + String endpoint = request.getMethod() + " " + request.getRequestURI(); + if (queryCount >= threshold) { + log.warn("N+1 의심 - {} 쿼리 {}건, {}ms (임계치 {}건)", endpoint, queryCount, elapsedMs, threshold); + } else if (queryCount > 0) { + log.debug("쿼리 측정 - {} 쿼리 {}건, {}ms", endpoint, queryCount, elapsedMs); + } + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/actuator") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs"); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java b/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java new file mode 100644 index 00000000..b7866ded --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java @@ -0,0 +1,31 @@ +package com.carecode.core.monitoring; + +/** 요청 하나가 실행한 SQL 수를 센다. N+1 을 추정이 아니라 숫자로 확인하기 위한 도구다. */ +public final class QueryCountHolder { + + private static final ThreadLocal COUNTER = new ThreadLocal<>(); + + private QueryCountHolder() { + } + + public static void start() { + COUNTER.set(new int[1]); + } + + public static void increment() { + int[] counter = COUNTER.get(); + if (counter != null) { + counter[0]++; + } + } + + public static int get() { + int[] counter = COUNTER.get(); + return counter == null ? 0 : counter[0]; + } + + /** 스레드 풀 재사용 시 카운트가 누적되지 않도록 요청 종료 시 반드시 호출한다. */ + public static void clear() { + COUNTER.remove(); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java b/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java new file mode 100644 index 00000000..39e09634 --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java @@ -0,0 +1,13 @@ +package com.carecode.core.monitoring; + +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** Hibernate 가 SQL 을 실행할 때마다 호출된다. SQL 자체는 바꾸지 않고 개수만 센다. */ +public class QueryCountInspector implements StatementInspector { + + @Override + public String inspect(String sql) { + QueryCountHolder.increment(); + return sql; + } +} diff --git a/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java b/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java new file mode 100644 index 00000000..3f584045 --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java @@ -0,0 +1,70 @@ +package com.carecode.core.scheduler; + +import com.carecode.domain.careFacility.entity.CareFacilityBooking; +import com.carecode.domain.careFacility.repository.CareFacilityBookingRepository; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.service.NotificationCreationService; +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.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 시설 예약 전날 리마인더. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class BookingReminderScheduler { + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("M월 d일 HH:mm"); + + private final CareFacilityBookingRepository bookingRepository; + private final UserRepository userRepository; + private final NotificationCreationService notificationCreationService; + + /** 매일 오후 6시에 다음날 예약을 안내한다. */ + @Scheduled(cron = "${app.scheduler.booking.cron:0 0 18 * * *}", zone = "Asia/Seoul") + @Transactional(readOnly = true) + public void sendBookingReminders() { + LocalDate tomorrow = LocalDate.now().plusDays(1); + + List bookings = bookingRepository.findBookingsBetween( + tomorrow.atStartOfDay(), tomorrow.plusDays(1).atStartOfDay()); + + int sent = 0; + for (CareFacilityBooking booking : bookings) { + // 취소된 예약은 안내하지 않는다. + if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED) { + continue; + } + try { + User user = userRepository.findByUserId(booking.getUserId()).orElse(null); + if (user == null) { + continue; + } + + String facilityName = booking.getFacility() != null ? booking.getFacility().getName() : "시설"; + String message = String.format("내일 %s 예약이 있습니다. (%s, %s)", + facilityName, + booking.getStartTime().format(TIME_FORMAT), + booking.getChildName() != null ? booking.getChildName() : ""); + + notificationCreationService.createAndSend( + user, Notification.NotificationType.SYSTEM, "예약 안내", message); + sent++; + } catch (Exception e) { + log.error("예약 리마인더 발송 실패 - bookingId={}", booking.getId(), e); + } + } + + log.info("예약 리마인더 발송 완료 - 대상={}, 성공={}", bookings.size(), sent); + } +} diff --git a/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java b/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java new file mode 100644 index 00000000..bfc5e949 --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java @@ -0,0 +1,35 @@ +package com.carecode.core.scheduler; + +import com.carecode.domain.user.repository.EmailVerificationTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 주기적인 데이터 정리. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DataCleanupScheduler { + + private final EmailVerificationTokenRepository emailVerificationTokenRepository; + + /** + * 만료·사용 완료된 이메일 인증 토큰 정리. 매일 새벽 4시. + * 정리하지 않으면 가입 시도마다 행이 쌓여 테이블이 무한히 커진다. + */ + @Scheduled(cron = "${app.scheduler.cleanup.cron:0 0 4 * * *}", zone = "Asia/Seoul") + @Transactional + public void cleanupExpiredVerificationTokens() { + // 만료 직후 바로 지우지 않고 하루 여유를 둬서, 사용자 문의 시 이력을 확인할 수 있게 한다. + int deleted = emailVerificationTokenRepository.deleteExpiredOrUsed(LocalDateTime.now().minusDays(1)); + if (deleted > 0) { + log.info("만료된 이메일 인증 토큰 정리 완료 - 건수={}", deleted); + } + } +} diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java new file mode 100644 index 00000000..3c9be021 --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -0,0 +1,63 @@ +package com.carecode.core.scheduler; + +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; +import com.carecode.core.client.sync.SyncResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** 공공데이터 주기 동기화. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PublicDataSyncScheduler { + + private final NationwideChildcareFacilitySyncService facilitySyncService; + private final KindergartenSyncService kindergartenSyncService; + private final GovernmentBenefitSyncService benefitSyncService; + private final PediatricHospitalSyncService hospitalSyncService; + + /** 전국 어린이집 동기화. */ + @Scheduled(cron = "${app.scheduler.public-data.facility-cron:0 0 3 * * MON}", zone = "Asia/Seoul") + public void syncChildcareFacilities() { + SyncResult result = facilitySyncService.sync(); + logResult("전국 어린이집", result); + } + + /** 전국 유치원 동기화. 어린이집 작업과 겹치지 않게 시간을 벌린다. */ + @Scheduled(cron = "${app.scheduler.public-data.kindergarten-cron:0 0 4 * * MON}", zone = "Asia/Seoul") + public void syncKindergartens() { + SyncResult result = kindergartenSyncService.sync(); + logResult("전국 유치원", result); + } + + /** 정부 지원 서비스(보조금24) 동기화. */ + @Scheduled(cron = "${app.scheduler.public-data.benefit-cron:0 30 3 * * *}", zone = "Asia/Seoul") + public void syncGovernmentBenefits() { + SyncResult result = benefitSyncService.sync(); + logResult("정부 지원 서비스", result); + } + + /** 소아청소년과 병원 동기화. */ + @Scheduled(cron = "${app.scheduler.public-data.hospital-cron:0 0 3 * * TUE}", zone = "Asia/Seoul") + public void syncPediatricHospitals() { + SyncResult result = hospitalSyncService.sync(); + logResult("소아청소년과 병원", result); + } + + private void logResult(String label, SyncResult result) { + if (!result.isCompleted()) { + log.warn("{} 동기화 미완료 - {}", label, result); + return; + } + if (result.getTotalProcessed() == 0 && result.getFailed() == 0) { + log.debug("{} 동기화: 변경 없음", label); + return; + } + log.info("{} 동기화 완료 - {}", label, result); + } +} diff --git a/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java b/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java new file mode 100644 index 00000000..03b27ffc --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java @@ -0,0 +1,82 @@ +package com.carecode.core.scheduler; + +import com.carecode.domain.health.entity.VaccinationSchedule; +import com.carecode.domain.health.repository.VaccinationScheduleRepository; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.service.NotificationCreationService; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +/** + * 예방접종 사전 알림. + * + *

접종 예정일 D-{@code reminderDaysBefore} 구간에 들어온 일정을 찾아 보호자에게 알린다. + * 이미 알림을 보낸 일정은 {@code reminderSentAt} 으로 걸러 중복 발송하지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class VaccinationReminderScheduler { + + private final VaccinationScheduleRepository scheduleRepository; + private final NotificationCreationService notificationCreationService; + + @Value("${app.scheduler.vaccination.reminder-days-before:7}") + private int reminderDaysBefore; + + /** 매일 오전 9시. */ + @Scheduled(cron = "${app.scheduler.vaccination.cron:0 0 9 * * *}", zone = "Asia/Seoul") + @Transactional + public void sendVaccinationReminders() { + LocalDate today = LocalDate.now(); + LocalDate until = today.plusDays(reminderDaysBefore); + + List targets = scheduleRepository.findPendingReminders(today, until); + if (targets.isEmpty()) { + log.debug("예방접종 알림 대상 없음"); + return; + } + + int sent = 0; + for (VaccinationSchedule schedule : targets) { + try { + Child child = schedule.getChild(); + User parent = child != null ? child.getUser() : null; + if (parent == null) { + continue; + } + + long daysLeft = today.until(schedule.getDueDate()).getDays(); + String title = "예방접종 일정 안내"; + String message = String.format( + "%s의 %s %d차 접종 예정일이 %s 남았습니다. (예정일: %s)", + child.getName(), + schedule.getVaccineType().getDisplayName(), + schedule.getDoseNumber(), + daysLeft <= 0 ? "오늘까지" : daysLeft + "일", + schedule.getDueDate()); + + notificationCreationService.createAndSend( + parent, Notification.NotificationType.HEALTH, title, message); + + schedule.markReminderSent(); + sent++; + } catch (Exception e) { + // 한 건 실패가 나머지 발송을 막지 않도록 한다. + log.error("예방접종 알림 발송 실패 - scheduleId={}", schedule.getId(), e); + } + } + + scheduleRepository.saveAll(targets); + log.info("예방접종 알림 발송 완료 - 대상={}, 성공={}", targets.size(), sent); + } +} diff --git a/src/main/java/com/carecode/core/search/FullTextSearchSupport.java b/src/main/java/com/carecode/core/search/FullTextSearchSupport.java new file mode 100644 index 00000000..667bd810 --- /dev/null +++ b/src/main/java/com/carecode/core/search/FullTextSearchSupport.java @@ -0,0 +1,38 @@ +package com.carecode.core.search; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** 전문 검색 사용 가능 여부와 키워드 정규화. H2 등 MATCH AGAINST 미지원 DB에서는 끌 수 있다. */ +@Slf4j +@Component +public class FullTextSearchSupport { + + /** FULLTEXT 인덱스는 너무 짧은 토큰을 무시하므로 그 이하는 LIKE 로 처리한다. */ + private static final int MIN_KEYWORD_LENGTH = 2; + + private final boolean enabled; + + public FullTextSearchSupport(@Value("${app.search.fulltext-enabled:true}") boolean enabled) { + this.enabled = enabled; + if (!enabled) { + log.info("전문 검색이 비활성화되어 LIKE 검색으로 동작합니다."); + } + } + + /** 이 키워드를 전문 검색으로 처리할 수 있는지. */ + public boolean canUseFullText(String keyword) { + return enabled && normalize(keyword) != null; + } + + /** 검색 연산자로 해석될 수 있는 문자를 제거한다. 제거 후 유효하지 않으면 null. */ + public String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + // +, -, *, ", ~, <, >, ( ) 는 불리언 모드 연산자이거나 파싱을 깨뜨린다. + String cleaned = keyword.replaceAll("[+\\-*\"~<>()@]", " ").replaceAll("\\s+", " ").trim(); + return cleaned.length() >= MIN_KEYWORD_LENGTH ? cleaned : null; + } +} diff --git a/src/main/java/com/carecode/core/security/CustomUserDetailsService.java b/src/main/java/com/carecode/core/security/CustomUserDetailsService.java index f25022cd..b81f7b70 100644 --- a/src/main/java/com/carecode/core/security/CustomUserDetailsService.java +++ b/src/main/java/com/carecode/core/security/CustomUserDetailsService.java @@ -22,7 +22,8 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User user = userRepository.findByEmail(email) + // 탈퇴(soft delete)한 계정이 로그인되지 않도록 deletedAt 조건을 포함해 조회한다. + User user = userRepository.findByEmailAndDeletedAtIsNull(email) .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); String password = user.getPassword(); if (password == null || password.isBlank()) { diff --git a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java index 075a2743..17b3cc9c 100644 --- a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java @@ -41,25 +41,33 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (StringUtils.hasText(token)) { log.debug("토큰 유효성 검증 시작"); - boolean isValid = jwtService.validateToken(token); + // Access Token 만 허용한다. Refresh Token 으로는 API 인증이 되지 않아야 한다. + boolean isValid = jwtService.validateAccessToken(token); log.debug("토큰 유효성 검증 결과: {}", isValid); - + if (isValid) { String userId = jwtService.getUserIdFromToken(token); String email = jwtService.getEmailFromToken(token); String role = jwtService.getRoleFromToken(token); - String name = jwtService.getNameFromToken(token); - + + if (!StringUtils.hasText(email) || !StringUtils.hasText(role)) { + // role 이 없으면 "ROLE_null" 권한으로 인증되던 문제를 차단한다. + log.warn("JWT 인증 거부: 필수 클레임 누락 (email={}, role={})", email, role); + SecurityContextHolder.clearContext(); + filterChain.doFilter(request, response); + return; + } + // 인증 정보 생성 - email을 principal로 사용 (일관성을 위해) UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( email, // principal을 email로 설정 (일관성) null, // credentials는 null Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)) ); - + // SecurityContext에 인증 정보 설정 SecurityContextHolder.getContext().setAuthentication(authentication); - + log.debug("JWT 인증 성공: userId={}, email={}, role={}", userId, email, role); } else { log.debug("JWT 토큰이 유효하지 않음"); @@ -95,7 +103,9 @@ private String extractTokenFromRequest(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getRequestURI(); - // 다음 경로들은 JWT 인증을 건너뜀 + // 다음 경로들은 JWT 인증을 건너뜀. + // 주의: /admin 은 여기서 제외하면 안 된다. 세션 기반 어드민 체인(SecurityConfig 참고)이 + // 별도로 처리하며, 과거처럼 스킵하면 /admin/** 이 인증 주체 없이 항상 403 이 된다. boolean shouldNotFilter = path.startsWith("/swagger-ui") || path.startsWith("/api-docs") || path.startsWith("/v3/api-docs") || @@ -109,9 +119,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce path.startsWith("/auth/kakao") || path.startsWith("/oauth2") || path.startsWith("/login/oauth2") || - path.equals("/kakao-callback.html") || - path.startsWith("/admin"); - + path.equals("/kakao-callback.html"); + return shouldNotFilter; } } \ No newline at end of file diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index e7bddccb..d474bcf5 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; @@ -48,6 +49,12 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, .toList(); } + /** + * 전체 API 체인. + *

어드민도 동일한 JWT 인증을 사용하고 {@code /api/admin/**} 에서 ADMIN 역할로 구분한다. + * (과거에는 세션 기반 어드민 화면이 별도로 있었으나, 템플릿 엔진이 없어 렌더링 자체가 불가능했고 + * 컨트롤러가 STATELESS 정책 아래에서 SecurityContextHolder 를 직접 조작해 항상 403 이었다.) + */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -112,9 +119,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 관리자 API (ADMIN 권한 필요) .requestMatchers("/api/admin/**").hasRole("ADMIN") - .requestMatchers("/admin/login").permitAll() - .requestMatchers("/admin/**").hasRole("ADMIN") - + // 공개 API 엔드포인트 .requestMatchers("/facilities").permitAll() .requestMatchers("/facilities/type/**").permitAll() @@ -142,7 +147,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/hospitals/*/like").authenticated() .requestMatchers(HttpMethod.DELETE, "/hospitals/*/like").authenticated() - // 정책 API 엔드포인트 (공개 접근) + // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 + // 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. + .requestMatchers("/policies/recommendations").authenticated() + .requestMatchers("/policies/bookmarks").authenticated() + .requestMatchers("/policies/*/bookmarks").authenticated() .requestMatchers("/policies").permitAll() .requestMatchers("/policies/search").permitAll() .requestMatchers("/policies/categories").permitAll() @@ -186,4 +195,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + /** + * {@code @Component} 로 선언된 필터는 Boot 가 서블릿 컨테이너에 자동 등록한다. + * 그대로 두면 어드민 체인에서도 JWT 필터가 돌면서 세션으로 복원된 인증을 지운다. + * 필터 체인 등록은 {@link #filterChain(HttpSecurity)} 에서만 이뤄지도록 자동 등록을 끈다. + */ + @Bean + public FilterRegistrationBean disableJwtFilterAutoRegistration( + JwtAuthenticationFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } } \ No newline at end of file diff --git a/src/main/java/com/carecode/core/storage/FileStorageService.java b/src/main/java/com/carecode/core/storage/FileStorageService.java new file mode 100644 index 00000000..479799d1 --- /dev/null +++ b/src/main/java/com/carecode/core/storage/FileStorageService.java @@ -0,0 +1,25 @@ +package com.carecode.core.storage; + +import org.springframework.web.multipart.MultipartFile; + +/** + * 파일 저장소 추상화. + * + *

구현체를 바꾸면 로컬 디스크 ↔ S3 전환이 가능하도록 도메인 코드는 이 인터페이스에만 의존한다. + */ +public interface FileStorageService { + + /** + * 파일을 저장한다. + * + * @param file 업로드된 파일 + * @param directory 저장소 내 논리 디렉터리 (예: {@code health-records}, {@code profiles}) + * @return 저장 결과 메타데이터 + */ + StoredFile store(MultipartFile file, String directory); + + /** + * 저장된 파일을 삭제한다. 없는 키를 지워도 예외를 던지지 않는다. + */ + void delete(String key); +} diff --git a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java new file mode 100644 index 00000000..af8f62bf --- /dev/null +++ b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java @@ -0,0 +1,151 @@ +package com.carecode.core.storage; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * 로컬 디스크 기반 파일 저장소. + * + *

단일 인스턴스 배포를 전제로 한다. 다중 인스턴스로 확장할 때는 + * 같은 인터페이스로 S3 구현체를 만들어 교체한다. + */ +@Slf4j +@Service +public class LocalFileStorageService implements FileStorageService { + + /** 업로드를 허용할 확장자. 실행 가능한 파일이 올라가는 것을 막는다. */ + private static final Set ALLOWED_EXTENSIONS = + Set.of("jpg", "jpeg", "png", "gif", "webp", "heic", "pdf"); + + private static final Set ALLOWED_CONTENT_TYPES = + Set.of("image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "application/pdf"); + + private static final DateTimeFormatter DATE_PATH = DateTimeFormatter.ofPattern("yyyy/MM/dd"); + + private final Path rootLocation; + private final String publicBaseUrl; + private final long maxFileSize; + + public LocalFileStorageService( + @Value("${app.storage.local.root:./uploads}") String root, + @Value("${app.storage.public-base-url:/files}") String publicBaseUrl, + @Value("${app.storage.max-file-size-bytes:10485760}") long maxFileSize) { + this.rootLocation = Paths.get(root).toAbsolutePath().normalize(); + this.publicBaseUrl = publicBaseUrl.endsWith("/") + ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) + : publicBaseUrl; + this.maxFileSize = maxFileSize; + + try { + Files.createDirectories(rootLocation); + log.info("파일 저장소 초기화: {}", rootLocation); + } catch (IOException e) { + throw new IllegalStateException("파일 저장 디렉터리를 만들 수 없습니다: " + rootLocation, e); + } + } + + @Override + public StoredFile store(MultipartFile file, String directory) { + validate(file); + + String extension = extractExtension(file.getOriginalFilename()); + // 원본 파일명을 그대로 쓰면 경로 조작(../)과 파일명 충돌 위험이 있으므로 UUID 로 대체한다. + String storedName = UUID.randomUUID().toString().replace("-", "") + "." + extension; + String relativeDir = sanitizeDirectory(directory) + "/" + LocalDate.now().format(DATE_PATH); + String key = relativeDir + "/" + storedName; + + Path targetDir = rootLocation.resolve(relativeDir).normalize(); + if (!targetDir.startsWith(rootLocation)) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "잘못된 저장 경로입니다."); + } + + try { + Files.createDirectories(targetDir); + try (InputStream in = file.getInputStream()) { + Files.copy(in, targetDir.resolve(storedName), StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + log.error("파일 저장 실패 - key={}", key, e); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "파일 저장에 실패했습니다."); + } + + return StoredFile.builder() + .key(key) + .url(publicBaseUrl + "/" + key) + .originalFilename(StringUtils.cleanPath( + file.getOriginalFilename() != null ? file.getOriginalFilename() : storedName)) + .contentType(file.getContentType()) + .size(file.getSize()) + .build(); + } + + @Override + public void delete(String key) { + if (key == null || key.isBlank()) { + return; + } + try { + Path target = rootLocation.resolve(key).normalize(); + if (!target.startsWith(rootLocation)) { + log.warn("저장소 밖 경로 삭제 시도를 차단했습니다: {}", key); + return; + } + Files.deleteIfExists(target); + } catch (IOException e) { + log.error("파일 삭제 실패 - key={}", key, e); + } + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "업로드할 파일이 비어 있습니다."); + } + if (file.getSize() > maxFileSize) { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "파일 크기는 " + (maxFileSize / 1024 / 1024) + "MB를 넘을 수 없습니다."); + } + + String extension = extractExtension(file.getOriginalFilename()); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "허용되지 않는 파일 형식입니다: " + extension); + } + + String contentType = file.getContentType(); + if (contentType != null && !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "허용되지 않는 파일 형식입니다: " + contentType); + } + } + + private String extractExtension(String filename) { + if (filename == null) { + return ""; + } + String cleaned = StringUtils.cleanPath(filename); + int dot = cleaned.lastIndexOf('.'); + return dot >= 0 ? cleaned.substring(dot + 1).toLowerCase(Locale.ROOT) : ""; + } + + /** 디렉터리 이름에 경로 구분자나 상위 이동이 들어오지 못하게 한다. */ + private String sanitizeDirectory(String directory) { + if (directory == null || directory.isBlank()) { + return "misc"; + } + return directory.replaceAll("[^a-zA-Z0-9_-]", ""); + } +} diff --git a/src/main/java/com/carecode/core/storage/StoredFile.java b/src/main/java/com/carecode/core/storage/StoredFile.java new file mode 100644 index 00000000..f3cc81e0 --- /dev/null +++ b/src/main/java/com/carecode/core/storage/StoredFile.java @@ -0,0 +1,25 @@ +package com.carecode.core.storage; + +import lombok.Builder; +import lombok.Getter; + +/** + * 저장된 파일의 메타데이터. + */ +@Getter +@Builder +public class StoredFile { + + /** 저장소 내 키(경로). 삭제·조회 시 사용한다. */ + private final String key; + + /** 클라이언트가 접근할 수 있는 URL. */ + private final String url; + + /** 업로드 당시 원본 파일명. */ + private final String originalFilename; + + private final String contentType; + + private final long size; +} diff --git a/src/main/java/com/carecode/core/util/AgeRangeParser.java b/src/main/java/com/carecode/core/util/AgeRangeParser.java new file mode 100644 index 00000000..7c7cbc2d --- /dev/null +++ b/src/main/java/com/carecode/core/util/AgeRangeParser.java @@ -0,0 +1,114 @@ +package com.carecode.core.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 공공데이터의 자유 텍스트 지원대상에서 연령 조건을 개월 단위로 뽑아낸다. */ +public final class AgeRangeParser { + + /** 정책 연령 필드의 단위는 개월이다. 세 단위 표기는 모두 여기서 개월로 환산한다. */ + private static final int MONTHS_PER_YEAR = 12; + + /** 육아 정책의 상한. 이보다 큰 숫자는 연령이 아니라 금액·연도일 가능성이 높다. */ + private static final int MAX_PLAUSIBLE_YEAR = 19; + private static final int MAX_PLAUSIBLE_MONTH = MAX_PLAUSIBLE_YEAR * MONTHS_PER_YEAR; + + // "생후 12~23개월", "0~23 개월" + private static final Pattern MONTH_RANGE = Pattern.compile("(\\d{1,3})\\s*[~\\-–]\\s*(\\d{1,3})\\s*개월"); + // "24개월 미만", "36개월 이하", "12개월 이상" + private static final Pattern MONTH_BOUND = Pattern.compile("(\\d{1,3})\\s*개월\\s*(미만|이하|이상)"); + // "만 3~5세", "만 3세 ~ 만 5세", "3세~5세" + private static final Pattern YEAR_RANGE = + Pattern.compile("(?:만\\s*)?(\\d{1,2})\\s*세?\\s*[~\\-–]\\s*(?:만\\s*)?(\\d{1,2})\\s*세"); + // "만 7세 미만", "만 8세 이하", "만 6세 이상" + private static final Pattern YEAR_BOUND = Pattern.compile("(?:만\\s*)?(\\d{1,2})\\s*세\\s*(미만|이하|이상)"); + + private AgeRangeParser() { + } + + /** 연령 조건. 경계가 없으면 해당 값이 null 이다. */ + public record AgeRange(Integer minMonths, Integer maxMonths) { + } + + /** 연령 조건을 찾지 못하면 null 을 반환한다. 억지로 추정하지 않는다. */ + public static AgeRange parse(String text) { + if (text == null || text.isBlank()) { + return null; + } + + // 개월 표기가 더 정확하므로 먼저 본다. + AgeRange range = matchMonthRange(text); + if (range != null) { + return range; + } + range = matchYearRange(text); + if (range != null) { + return range; + } + range = matchMonthBound(text); + if (range != null) { + return range; + } + return matchYearBound(text); + } + + private static AgeRange matchMonthRange(String text) { + Matcher m = MONTH_RANGE.matcher(text); + if (!m.find()) { + return null; + } + int min = Integer.parseInt(m.group(1)); + int max = Integer.parseInt(m.group(2)); + return isValid(min, max, MAX_PLAUSIBLE_MONTH) ? new AgeRange(min, max) : null; + } + + private static AgeRange matchYearRange(String text) { + Matcher m = YEAR_RANGE.matcher(text); + if (!m.find()) { + return null; + } + int minYear = Integer.parseInt(m.group(1)); + int maxYear = Integer.parseInt(m.group(2)); + if (!isValid(minYear, maxYear, MAX_PLAUSIBLE_YEAR)) { + return null; + } + // "만 3~5세" 는 5세 생일부터 6세 생일 전날까지 포함하므로 상한은 71개월이다. + return new AgeRange(minYear * MONTHS_PER_YEAR, maxYear * MONTHS_PER_YEAR + MONTHS_PER_YEAR - 1); + } + + private static AgeRange matchMonthBound(String text) { + Matcher m = MONTH_BOUND.matcher(text); + if (!m.find()) { + return null; + } + int months = Integer.parseInt(m.group(1)); + if (months > MAX_PLAUSIBLE_MONTH) { + return null; + } + return switch (m.group(2)) { + case "미만" -> new AgeRange(0, months - 1); + case "이하" -> new AgeRange(0, months); + default -> new AgeRange(months, null); + }; + } + + private static AgeRange matchYearBound(String text) { + Matcher m = YEAR_BOUND.matcher(text); + if (!m.find()) { + return null; + } + int years = Integer.parseInt(m.group(1)); + if (years > MAX_PLAUSIBLE_YEAR) { + return null; + } + return switch (m.group(2)) { + case "미만" -> new AgeRange(0, years * MONTHS_PER_YEAR - 1); + case "이하" -> new AgeRange(0, years * MONTHS_PER_YEAR + MONTHS_PER_YEAR - 1); + default -> new AgeRange(years * MONTHS_PER_YEAR, null); + }; + } + + private static boolean isValid(int min, int max, int upperBound) { + return min <= max && max <= upperBound; + } +} diff --git a/src/main/java/com/carecode/core/util/BoundingBox.java b/src/main/java/com/carecode/core/util/BoundingBox.java new file mode 100644 index 00000000..7b997873 --- /dev/null +++ b/src/main/java/com/carecode/core/util/BoundingBox.java @@ -0,0 +1,27 @@ +package com.carecode.core.util; + +/** 반경 검색용 위경도 사각 범위. 인덱스를 탈 수 있는 BETWEEN 조건으로 후보를 좁히는 데 쓴다. */ +public record BoundingBox(double minLat, double maxLat, double minLng, double maxLng) { + + /** 위도 1도당 거리(km). 경도와 달리 위치에 관계없이 거의 일정하다. */ + private static final double KM_PER_LAT_DEGREE = 111.045; + + /** 중심점과 반경(km)으로 사각 범위를 만든다. */ + public static BoundingBox around(double latitude, double longitude, double radiusKm) { + double latDelta = radiusKm / KM_PER_LAT_DEGREE; + + // 경도 1도의 거리는 위도가 높을수록 짧아진다. 극지방에서 0으로 나누지 않도록 하한을 둔다. + double cosLat = Math.max(Math.cos(Math.toRadians(latitude)), 0.01); + double lngDelta = radiusKm / (KM_PER_LAT_DEGREE * cosLat); + + return new BoundingBox( + clampLat(latitude - latDelta), + clampLat(latitude + latDelta), + longitude - lngDelta, + longitude + lngDelta); + } + + private static double clampLat(double lat) { + return Math.max(-90.0, Math.min(90.0, lat)); + } +} diff --git a/src/main/java/com/carecode/core/util/ClientIpResolver.java b/src/main/java/com/carecode/core/util/ClientIpResolver.java new file mode 100644 index 00000000..3ec4d5c4 --- /dev/null +++ b/src/main/java/com/carecode/core/util/ClientIpResolver.java @@ -0,0 +1,46 @@ +package com.carecode.core.util; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 클라이언트 IP 해석기. + * + *

{@code X-Forwarded-For} 는 클라이언트가 임의로 붙일 수 있는 헤더다. + * 신뢰할 수 있는 리버스 프록시 뒤에 있지 않은데 이 헤더를 그대로 쓰면 + * 헤더 값만 바꿔가며 IP 기반 rate limit 을 무한히 우회할 수 있다. + * + *

그래서 {@code app.rate-limit.trust-forwarded-headers=true} 인 경우에만 + * 프록시 헤더를 사용하고, 기본값은 TCP 연결의 실제 원격 주소를 쓴다. + */ +@Slf4j +@Component +public class ClientIpResolver { + + private final boolean trustForwardedHeaders; + + public ClientIpResolver( + @Value("${app.rate-limit.trust-forwarded-headers:false}") boolean trustForwardedHeaders) { + this.trustForwardedHeaders = trustForwardedHeaders; + if (trustForwardedHeaders) { + log.info("X-Forwarded-For 헤더를 신뢰하도록 설정되었습니다. 반드시 신뢰 가능한 프록시 뒤에서만 사용하세요."); + } + } + + public String resolve(HttpServletRequest request) { + if (!trustForwardedHeaders) { + return request.getRemoteAddr(); + } + + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded == null || forwarded.isBlank() || "unknown".equalsIgnoreCase(forwarded)) { + return request.getRemoteAddr(); + } + + // "client, proxy1, proxy2" 형식에서 최초 클라이언트 주소를 사용한다. + String first = forwarded.split(",")[0].trim(); + return first.isEmpty() ? request.getRemoteAddr() : first; + } +} diff --git a/src/main/java/com/carecode/core/util/PageRequestUtil.java b/src/main/java/com/carecode/core/util/PageRequestUtil.java new file mode 100644 index 00000000..5fa5239b --- /dev/null +++ b/src/main/java/com/carecode/core/util/PageRequestUtil.java @@ -0,0 +1,33 @@ +package com.carecode.core.util; + +/** + * 목록 API 의 페이지 파라미터를 안전한 범위로 보정한다. + * + *

클라이언트가 {@code size=1000000} 같은 값을 보내면 페이징이 무의미해지므로 + * 상한을 강제한다. + */ +public final class PageRequestUtil { + + /** 페이지 파라미터가 없을 때 사용할 기본 크기. */ + public static final int DEFAULT_PAGE_SIZE = 100; + + /** 한 번에 조회할 수 있는 최대 행 수. */ + public static final int MAX_PAGE_SIZE = 200; + + private PageRequestUtil() { + } + + public static int normalizePage(Integer page) { + if (page == null || page < 0) { + return 0; + } + return page; + } + + public static int normalizeSize(Integer size) { + if (size == null || size <= 0) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(size, MAX_PAGE_SIZE); + } +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminAuthController.java b/src/main/java/com/carecode/domain/admin/controller/AdminAuthController.java deleted file mode 100644 index a598cb12..00000000 --- a/src/main/java/com/carecode/domain/admin/controller/AdminAuthController.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.carecode.domain.admin.controller; - -import com.carecode.core.security.CustomUserDetailsService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -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.servlet.mvc.support.RedirectAttributes; - -@Slf4j -@Controller -@RequestMapping("/admin") -@RequiredArgsConstructor -public class AdminAuthController { - - private final CustomUserDetailsService customUserDetailsService; - private final PasswordEncoder passwordEncoder; - - @GetMapping("/login") - public String adminLoginPage() { - return "admin/login"; - } - - @PostMapping("/login") - public String adminLogin(@RequestParam String username, @RequestParam String password, RedirectAttributes redirectAttributes) { - UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); - - if (userDetails != null && passwordEncoder.matches(password, userDetails.getPassword())) { - // ADMIN 권한 확인 - boolean isAdmin = userDetails.getAuthorities().stream() - .anyMatch(authority -> authority.getAuthority().equals("ROLE_ADMIN")); - - if (!isAdmin) { - redirectAttributes.addFlashAttribute("error", "관리자 권한이 필요합니다."); - return "redirect:/admin/login?error"; - } - - Authentication authentication = new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities() - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - return "redirect:/admin/dashboard"; - } else { - throw new RuntimeException("Invalid credentials"); - } - } -} \ No newline at end of file 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 e4f52c21..85e601cf 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java @@ -1,229 +1,159 @@ package com.carecode.domain.admin.controller; import com.carecode.core.annotation.LogExecutionTime; -import com.carecode.core.annotation.RequireAdminRole; import com.carecode.core.controller.BaseController; import com.carecode.domain.admin.dto.AdminBookingDetailResponse; -import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; import com.carecode.domain.admin.dto.AdminBookingSearchRequest; import com.carecode.domain.admin.dto.AdminBookingSearchResponse; -import com.carecode.domain.admin.dto.AdminStatusUpdateRequest; import com.carecode.domain.admin.dto.AdminBookingStatsResponse; +import com.carecode.domain.admin.dto.AdminStatusUpdateRequest; import com.carecode.domain.admin.service.CareFacilityBookingAdminService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.LocalDate; +import java.util.Map; /** - * 관리자용 육아 시설 예약 관리 컨트롤러 + * 관리자용 육아 시설 예약 관리 API. + * + *

접근 제어는 SecurityConfig 의 {@code /api/admin/**} → hasRole("ADMIN") 규칙이 담당한다. */ @Slf4j -@Controller -@RequestMapping("/admin/facilities/bookings") +@RestController +@RequestMapping("/api/admin/facilities/bookings") @RequiredArgsConstructor -@RequireAdminRole +@Tag(name = "어드민 - 시설 예약", description = "관리자 전용 예약 관리 API") public class AdminCareFacilityBookingController extends BaseController { private final CareFacilityBookingAdminService adminBookingService; - // 예약 목록 페이지 @GetMapping @LogExecutionTime - public String bookingList(@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "20") Integer size, - @RequestParam(required = false) Long facilityId, @RequestParam(required = false) String status, - @RequestParam(required = false) String keyword, Model model) { - AdminBookingSearchRequest request = AdminBookingSearchRequest.builder() - .page(page) - .size(size) - .facilityId(facilityId) - .status(status) - .keyword(keyword) - .build(); - - AdminBookingSearchResponse response = adminBookingService.searchBookings(request); - - model.addAttribute("bookings", response.getBookings()); - model.addAttribute("pagination", response); - model.addAttribute("searchRequest", request); - - return "admin/facilities/bookings/list"; - } + @Operation(summary = "예약 목록 조회") + public ResponseEntity bookingList( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(required = false) Long facilityId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + AdminBookingSearchRequest request = AdminBookingSearchRequest.builder() + .page(page) + .size(size) + .facilityId(facilityId) + .status(status) + .keyword(keyword) + .build(); - // 예약 상세 페이지 + return ResponseEntity.ok(adminBookingService.searchBookings(request)); + } @GetMapping("/{bookingId}") @LogExecutionTime - public String bookingDetail(@PathVariable Long bookingId, Model model) { - AdminBookingDetailResponse booking = adminBookingService.getBookingDetail(bookingId); - model.addAttribute("booking", booking); - return "admin/facilities/bookings/detail"; + @Operation(summary = "예약 상세 조회") + public ResponseEntity bookingDetail(@PathVariable Long bookingId) { + return ResponseEntity.ok(adminBookingService.getBookingDetail(bookingId)); } - - // 예약 상태 변경 - - @PostMapping("/{bookingId}/status") + @PatchMapping("/{bookingId}/status") @LogExecutionTime - public String updateBookingStatus(@PathVariable Long bookingId, @RequestParam String status, - @RequestParam(required = false) String reason, - RedirectAttributes redirectAttributes) { - - AdminStatusUpdateRequest request = AdminStatusUpdateRequest.builder() - .status(status) - .reason(reason) - .build(); + @Operation(summary = "예약 상태 변경") + public ResponseEntity updateBookingStatus( + @PathVariable Long bookingId, + @Valid @RequestBody AdminStatusUpdateRequest request) { adminBookingService.updateBookingStatus(bookingId, request); - redirectAttributes.addFlashAttribute("success", "예약 상태가 성공적으로 변경되었습니다."); - - return "redirect:/admin/facilities/bookings/" + bookingId; + return ResponseEntity.ok(adminBookingService.getBookingDetail(bookingId)); } - - // 예약 삭제 - - @PostMapping("/{bookingId}/delete") + @DeleteMapping("/{bookingId}") @LogExecutionTime - public String deleteBooking(@PathVariable Long bookingId, RedirectAttributes redirectAttributes) { + @Operation(summary = "예약 삭제") + public ResponseEntity deleteBooking(@PathVariable Long bookingId) { adminBookingService.deleteBooking(bookingId); - redirectAttributes.addFlashAttribute("success", "예약이 성공적으로 삭제되었습니다."); - - return "redirect:/admin/facilities/bookings"; + return ResponseEntity.noContent().build(); } - - // 예약 통계 페이지 - @GetMapping("/statistics") @LogExecutionTime - public String bookingStatistics(Model model) { - AdminBookingStatsResponse stats = adminBookingService.getBookingStats(); - model.addAttribute("stats", stats); - return "admin/facilities/bookings/statistics"; + @Operation(summary = "예약 통계 조회") + public ResponseEntity bookingStatistics() { + return ResponseEntity.ok(adminBookingService.getBookingStats()); } - - // 오늘의 예약 페이지 - @GetMapping("/today") @LogExecutionTime - public String todayBookings(@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "20") Integer size, Model model) { - AdminBookingSearchRequest request = AdminBookingSearchRequest.builder() - .page(page) - .size(size) - .build(); - - // 오늘 날짜로 검색 - request.setStartDate(java.time.LocalDateTime.now().toLocalDate().atStartOfDay()); - request.setEndDate(java.time.LocalDateTime.now().toLocalDate().atTime(23, 59, 59)); - - AdminBookingSearchResponse response = adminBookingService.searchBookings(request); - - model.addAttribute("bookings", response.getBookings()); - model.addAttribute("pagination", response); - model.addAttribute("isToday", true); - - return "admin/facilities/bookings/today"; + @Operation(summary = "오늘의 예약 조회") + public ResponseEntity todayBookings( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + return ResponseEntity.ok(adminBookingService.searchBookings(todayRequest(page, size))); } - - // 시설별 예약 페이지 - @GetMapping("/facility/{facilityId}") @LogExecutionTime - public String facilityBookings(@PathVariable Long facilityId, @RequestParam(defaultValue = "0") Integer page, - @RequestParam(defaultValue = "20") Integer size, Model model) { + @Operation(summary = "시설별 예약 조회") + public ResponseEntity facilityBookings( + @PathVariable Long facilityId, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + AdminBookingSearchRequest request = AdminBookingSearchRequest.builder() .facilityId(facilityId) .page(page) .size(size) .build(); - AdminBookingSearchResponse response = adminBookingService.searchBookings(request); - - model.addAttribute("bookings", response.getBookings()); - model.addAttribute("pagination", response); - model.addAttribute("facilityId", facilityId); - - return "admin/facilities/bookings/facility"; + return ResponseEntity.ok(adminBookingService.searchBookings(request)); } - - // 상태별 예약 페이지 - @GetMapping("/status/{status}") @LogExecutionTime - public String statusBookings(@PathVariable String status, @RequestParam(defaultValue = "0") Integer page, - @RequestParam(defaultValue = "20") Integer size, Model model) { + @Operation(summary = "상태별 예약 조회") + public ResponseEntity statusBookings( + @PathVariable String status, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + AdminBookingSearchRequest request = AdminBookingSearchRequest.builder() .status(status) .page(page) .size(size) .build(); - - AdminBookingSearchResponse response = adminBookingService.searchBookings(request); - - model.addAttribute("bookings", response.getBookings()); - model.addAttribute("pagination", response); - model.addAttribute("status", status); - - return "admin/facilities/bookings/status"; - } - - // 예약 생성 페이지 (관리자가 직접 예약 생성) - - @GetMapping("/create") - @LogExecutionTime - public String createBookingForm(Model model) { - model.addAttribute("bookingRequest", new CreateBookingRequest()); - return "admin/facilities/bookings/create"; + return ResponseEntity.ok(adminBookingService.searchBookings(request)); } - - // 예약 수정 페이지 - - @GetMapping("/{bookingId}/edit") - @LogExecutionTime - public String editBookingForm(@PathVariable Long bookingId, Model model) { - AdminBookingDetailResponse booking = adminBookingService.getBookingDetail(bookingId); - model.addAttribute("booking", booking); - return "admin/facilities/bookings/edit"; - } - - - // 대시보드 메인 페이지 (예약 요약) - @GetMapping("/dashboard") @LogExecutionTime - public String bookingDashboard(Model model) { - // 통계 정보 - AdminBookingStatsResponse stats = adminBookingService.getBookingStats(); - model.addAttribute("stats", stats); - - // 최근 예약 목록 (최근 10개) + @Operation(summary = "예약 대시보드 요약", description = "통계, 최근 예약, 오늘의 예약을 함께 반환합니다.") + public ResponseEntity> bookingDashboard() { AdminBookingSearchRequest recentRequest = AdminBookingSearchRequest.builder() .page(0) .size(10) .build(); - AdminBookingSearchResponse recentBookings = adminBookingService.searchBookings(recentRequest); - model.addAttribute("recentBookings", recentBookings.getBookings()); - // 오늘의 예약 (최근 5개) - AdminBookingSearchRequest todayRequest = AdminBookingSearchRequest.builder() - .page(0) - .size(5) - .build(); - todayRequest.setStartDate(java.time.LocalDateTime.now().toLocalDate().atStartOfDay()); - todayRequest.setEndDate(java.time.LocalDateTime.now().toLocalDate().atTime(23, 59, 59)); - - AdminBookingSearchResponse todayBookings = adminBookingService.searchBookings(todayRequest); - model.addAttribute("todayBookings", todayBookings.getBookings()); + return ResponseEntity.ok(Map.of( + "stats", adminBookingService.getBookingStats(), + "recentBookings", adminBookingService.searchBookings(recentRequest).getBookings(), + "todayBookings", adminBookingService.searchBookings(todayRequest(0, 5)).getBookings() + )); + } - return "admin/facilities/bookings/dashboard"; + private AdminBookingSearchRequest todayRequest(Integer page, Integer size) { + AdminBookingSearchRequest request = AdminBookingSearchRequest.builder() + .page(page) + .size(size) + .build(); + LocalDate today = LocalDate.now(); + request.setStartDate(today.atStartOfDay()); + request.setEndDate(today.atTime(23, 59, 59)); + return request; } -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java b/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java index ecd15a31..8d43dd46 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java @@ -1,58 +1,58 @@ package com.carecode.domain.admin.controller; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.domain.community.dto.response.CommunityPostResponse; import com.carecode.domain.community.entity.Post; +import com.carecode.domain.community.mapper.CommunityMapper; import com.carecode.domain.community.repository.PostRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -@Controller -@RequestMapping("/admin/community") +/** + * 어드민 커뮤니티 관리 API. + * + *

관리자는 게시글을 대신 작성하지 않는다. 모더레이션(조회/삭제)만 제공한다. + */ +@RestController +@RequestMapping("/api/admin/community/posts") @RequiredArgsConstructor +@Tag(name = "어드민 - 커뮤니티", description = "관리자 전용 게시글 모더레이션 API") public class AdminCommunityController { + private final PostRepository postRepository; + private final CommunityMapper communityMapper; @GetMapping - public String list(Model model) { - model.addAttribute("posts", postRepository.findAll()); - return "admin/community/list"; - } - - @GetMapping("/add") - public String addForm(Model model) { - model.addAttribute("post", new Post()); - return "admin/community/form"; - } - - @PostMapping("/add") - public String add(@ModelAttribute Post post) { - postRepository.save(post); - return "redirect:/admin/community"; + @Operation(summary = "게시글 목록 조회") + public ResponseEntity> list( + @PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ResponseEntity.ok(postRepository.findAll(pageable).map(communityMapper::toPostResponse)); } @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("post", postRepository.findById(id).orElse(null)); - return "admin/community/detail"; - } - - @GetMapping("/{id}/edit") - public String editForm(@PathVariable Long id, Model model) { - model.addAttribute("post", postRepository.findById(id).orElse(null)); - return "admin/community/form"; + @Operation(summary = "게시글 상세 조회") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(communityMapper.toPostResponse(findPost(id))); } - @PostMapping("/{id}/edit") - public String edit(@PathVariable Long id, @ModelAttribute Post post) { - post.setId(id); - postRepository.save(post); - return "redirect:/admin/community"; + @DeleteMapping("/{id}") + @Operation(summary = "게시글 삭제", description = "부적절한 게시글을 관리자 권한으로 삭제합니다.") + @Transactional + public ResponseEntity delete(@PathVariable Long id) { + postRepository.delete(findPost(id)); + return ResponseEntity.noContent().build(); } - @PostMapping("/{id}/delete") - public String delete(@PathVariable Long id) { - postRepository.deleteById(id); - return "redirect:/admin/community"; + private Post findPost(Long id) { + return postRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + id)); } -} \ No newline at end of file +} 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 33cf516b..1950388c 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java @@ -1,110 +1,109 @@ package com.carecode.domain.admin.controller; -import com.carecode.domain.user.repository.UserRepository; +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.health.entity.Hospital; -import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.user.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; 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; -import com.carecode.core.components.JsonMapComponent; -import java.time.LocalDateTime; -@Controller -@RequestMapping("/admin") +/** + * 어드민 대시보드 API. + */ +@RestController +@RequestMapping("/api/admin") @RequiredArgsConstructor +@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; + @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()); - @GetMapping("/dashboard") - public String dashboard(Model model) { - JsonMapComponent dashboardData = new JsonMapComponent(); - - // 1. 전체 수 - long userCount = userRepository.count(); - long hospitalCount = hospitalRepository.count(); - long policyCount = policyRepository.count(); - dashboardData.put("userCount", userCount); - dashboardData.put("hospitalCount", hospitalCount); - dashboardData.put("policyCount", policyCount); - - // 2. 최근 활동 (최신순 5개) - List> recentActivities = new ArrayList<>(); - List allUsers = userRepository.findTop2ByDeletedAtIsNullOrderByCreatedAtDesc(); - - for (int i = 0; i < Math.min(2, allUsers.size()); i++) { - User user = allUsers.get(i); - recentActivities.add(Map.of( - "type", "user", - "desc", "신규 사용자 가입: " + user.getName(), - "time", user.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) - )); - } + Map userTrend = userTrend(); + dashboard.put("userTrendLabels", new ArrayList<>(userTrend.keySet())); + dashboard.put("userTrendData", new ArrayList<>(userTrend.values())); + + return ResponseEntity.ok(dashboard); + } - List allHospitals = hospitalRepository.findTop2ByOrderByCreatedAtDesc(); + private List> recentActivities() { + List> activities = new ArrayList<>(); - for (int i = 0; i < Math.min(2, allHospitals.size()); i++) { - Hospital hospital = allHospitals.get(i); - recentActivities.add(Map.of( - "type", "hospital", - "desc", "병원 등록: " + hospital.getName(), - "time", hospital.getCreatedAt() != null ? hospital.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) : "-" + for (User user : userRepository.findTop2ByDeletedAtIsNullOrderByCreatedAtDesc()) { + activities.add(Map.of( + "type", "user", + "desc", "신규 사용자 가입: " + user.getName(), + "time", format(user.getCreatedAt()) )); } - - List allPolicies = policyRepository.findTop1ByOrderByCreatedAtDesc(); - - for (int i = 0; i < Math.min(1, allPolicies.size()); i++) { - Policy policy = allPolicies.get(i); - recentActivities.add(Map.of( - "type", "policy", - "desc", "정책 등록: " + policy.getTitle(), - "time", policy.getCreatedAt() != null ? policy.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) : "-" + for (Hospital hospital : hospitalRepository.findTop2ByOrderByCreatedAtDesc()) { + activities.add(Map.of( + "type", "hospital", + "desc", "병원 등록: " + hospital.getName(), + "time", format(hospital.getCreatedAt()) + )); + } + for (Policy policy : policyRepository.findTop1ByOrderByCreatedAtDesc()) { + activities.add(Map.of( + "type", "policy", + "desc", "정책 등록: " + policy.getTitle(), + "time", format(policy.getCreatedAt()) )); } - // 최신순 정렬 - recentActivities = recentActivities.stream() - .sorted(Comparator.comparing(m -> m.get("time"), Comparator.reverseOrder())) + return activities.stream() + .sorted(Comparator.comparing((Map m) -> m.get("time")).reversed()) .limit(5) .collect(Collectors.toList()); - dashboardData.put("recentActivities", recentActivities); + } - // 3. 가입자 추이 (최근 6개월) - Map userTrend = new LinkedHashMap<>(); + /** 최근 6개월 가입자 추이. 값이 없는 달도 0 으로 채워 그래프가 끊기지 않게 한다. */ + private Map userTrend() { LocalDate now = LocalDate.now(); LocalDateTime sixMonthsAgo = now.minusMonths(5).withDayOfMonth(1).atStartOfDay(); - List trendRows = userRepository.countUsersGroupedByMonthSince(sixMonthsAgo); - Map trendLookup = new HashMap<>(); - for (Object[] row : trendRows) { + + Map lookup = new HashMap<>(); + for (Object[] row : userRepository.countUsersGroupedByMonthSince(sixMonthsAgo)) { String key = String.format("%d-%02d", ((Number) row[0]).intValue(), ((Number) row[1]).intValue()); - trendLookup.put(key, ((Number) row[2]).longValue()); + lookup.put(key, ((Number) row[2]).longValue()); } + Map trend = new LinkedHashMap<>(); for (int i = 5; i >= 0; i--) { - LocalDate monthVal = now.minusMonths(i).withDayOfMonth(1); - String labelVal = monthVal.format(DateTimeFormatter.ofPattern("yyyy-MM")); - long count = trendLookup.getOrDefault(labelVal, 0L); - userTrend.put(labelVal, count); + String label = now.minusMonths(i).withDayOfMonth(1).format(MONTH); + trend.put(label, lookup.getOrDefault(label, 0L)); } + return trend; + } - dashboardData.put("userTrendLabels", new ArrayList<>(userTrend.keySet())); - dashboardData.put("userTrendData", new ArrayList<>(userTrend.values())); - - model.addAttribute("dashboard", dashboardData); - return "admin/dashboard"; + private String format(LocalDateTime dateTime) { + return dateTime != null ? dateTime.format(TIMESTAMP) : "-"; } -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java b/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java index 0b7ccaa9..7218dfa3 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java @@ -1,68 +1,58 @@ package com.carecode.domain.admin.controller; +import com.carecode.core.exception.HealthRecordNotFoundException; +import com.carecode.domain.health.dto.response.HealthRecordResponse; import com.carecode.domain.health.entity.HealthRecord; +import com.carecode.domain.health.mapper.HealthRecordMapper; import com.carecode.domain.health.repository.HealthRecordRepository; -import com.carecode.domain.user.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -@Controller -@RequestMapping("/admin/health") +/** + * 어드민 건강기록 관리 API. + * + *

건강기록은 민감정보다. 관리자가 대신 생성·수정하지 않고 조회와 삭제만 제공한다. + */ +@RestController +@RequestMapping("/api/admin/health/records") @RequiredArgsConstructor +@Tag(name = "어드민 - 건강기록", description = "관리자 전용 건강기록 관리 API") public class AdminHealthController { + private final HealthRecordRepository healthRecordRepository; - private final UserRepository userRepository; + private final HealthRecordMapper healthRecordMapper; @GetMapping - public String list(Model model) { - model.addAttribute("records", healthRecordRepository.findAll()); - return "admin/health/list"; - } - - @GetMapping("/add") - public String addForm(Model model) { - model.addAttribute("record", new HealthRecord()); - model.addAttribute("users", userRepository.findAll()); - return "admin/health/form"; - } - - @PostMapping("/add") - public String add(@ModelAttribute HealthRecord record, @RequestParam(required = false) Long userId) { - if (userId != null) { - userRepository.findById(userId).ifPresent(record::setUser); - } - healthRecordRepository.save(record); - return "redirect:/admin/health"; + @Operation(summary = "건강기록 목록 조회") + public ResponseEntity> list( + @PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ResponseEntity.ok(healthRecordRepository.findAll(pageable).map(healthRecordMapper::toResponse)); } @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("record", healthRecordRepository.findById(id).orElse(null)); - return "admin/health/detail"; - } - - @GetMapping("/{id}/edit") - public String editForm(@PathVariable Long id, Model model) { - model.addAttribute("record", healthRecordRepository.findById(id).orElse(null)); - model.addAttribute("users", userRepository.findAll()); - return "admin/health/form"; + @Operation(summary = "건강기록 상세 조회") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(healthRecordMapper.toResponse(findRecord(id))); } - @PostMapping("/{id}/edit") - public String edit(@PathVariable Long id, @ModelAttribute HealthRecord record, @RequestParam(required = false) Long userId) { - record.setId(id); - if (userId != null) { - userRepository.findById(userId).ifPresent(record::setUser); - } - healthRecordRepository.save(record); - return "redirect:/admin/health"; + @DeleteMapping("/{id}") + @Operation(summary = "건강기록 삭제") + @Transactional + public ResponseEntity delete(@PathVariable Long id) { + healthRecordRepository.delete(findRecord(id)); + return ResponseEntity.noContent().build(); } - @PostMapping("/{id}/delete") - public String delete(@PathVariable Long id) { - healthRecordRepository.deleteById(id); - return "redirect:/admin/health"; + private HealthRecord findRecord(Long id) { + return healthRecordRepository.findById(id) + .orElseThrow(() -> new HealthRecordNotFoundException("건강 기록을 찾을 수 없습니다: " + id)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java b/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java index cf5ef0e4..0213ee58 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java @@ -1,58 +1,55 @@ package com.carecode.domain.admin.controller; +import com.carecode.core.exception.HospitalNotFoundException; +import com.carecode.domain.health.dto.response.HospitalInfoResponse; import com.carecode.domain.health.entity.Hospital; +import com.carecode.domain.health.mapper.HospitalMapper; import com.carecode.domain.health.repository.HospitalRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -@Controller -@RequestMapping("/admin/hospitals") +/** + * 어드민 병원 관리 API. + */ +@RestController +@RequestMapping("/api/admin/hospitals") @RequiredArgsConstructor +@Tag(name = "어드민 - 병원", description = "관리자 전용 병원 관리 API") public class AdminHospitalController { + private final HospitalRepository hospitalRepository; + private final HospitalMapper hospitalMapper; @GetMapping - public String list(Model model) { - model.addAttribute("hospitals", hospitalRepository.findAll()); - return "admin/hospitals/list"; - } - - @GetMapping("/add") - public String addForm(Model model) { - model.addAttribute("hospital", new Hospital()); - return "admin/hospitals/form"; - } - - @PostMapping("/add") - public String add(@ModelAttribute Hospital hospital) { - hospitalRepository.save(hospital); - return "redirect:/admin/hospitals"; + @Operation(summary = "병원 목록 조회") + public ResponseEntity> list( + @PageableDefault(size = 50, sort = "name") Pageable pageable) { + return ResponseEntity.ok(hospitalRepository.findAll(pageable).map(hospitalMapper::toResponse)); } @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("hospital", hospitalRepository.findById(id).orElse(null)); - return "admin/hospitals/detail"; - } - - @GetMapping("/{id}/edit") - public String editForm(@PathVariable Long id, Model model) { - model.addAttribute("hospital", hospitalRepository.findById(id).orElse(null)); - return "admin/hospitals/form"; + @Operation(summary = "병원 상세 조회") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(hospitalMapper.toResponse(findHospital(id))); } - @PostMapping("/{id}/edit") - public String edit(@PathVariable Long id, @ModelAttribute Hospital hospital) { - hospital.setId(id); - hospitalRepository.save(hospital); - return "redirect:/admin/hospitals"; + @DeleteMapping("/{id}") + @Operation(summary = "병원 삭제") + @Transactional + public ResponseEntity delete(@PathVariable Long id) { + hospitalRepository.delete(findHospital(id)); + return ResponseEntity.noContent().build(); } - @PostMapping("/{id}/delete") - public String delete(@PathVariable Long id) { - hospitalRepository.deleteById(id); - return "redirect:/admin/hospitals"; + private Hospital findHospital(Long id) { + return hospitalRepository.findById(id) + .orElseThrow(() -> new HospitalNotFoundException(id)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java index 10a0d4b5..90694c34 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java @@ -1,68 +1,93 @@ package com.carecode.domain.admin.controller; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.core.exception.UserNotFoundException; +import com.carecode.domain.admin.dto.AdminNotificationCreateRequest; +import com.carecode.domain.notification.dto.response.NotificationInfoResponse; import com.carecode.domain.notification.entity.Notification; import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -@Controller -@RequestMapping("/admin/notifications") +/** + * 어드민 알림 관리 API. + */ +@RestController +@RequestMapping("/api/admin/notifications") @RequiredArgsConstructor +@Tag(name = "어드민 - 알림", description = "관리자 전용 알림 관리 API") public class AdminNotificationController { + private final NotificationRepository notificationRepository; private final UserRepository userRepository; @GetMapping - public String list(Model model) { - model.addAttribute("notifications", notificationRepository.findAll()); - return "admin/notifications/list"; + @Operation(summary = "알림 목록 조회") + public ResponseEntity> list( + @PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ResponseEntity.ok(notificationRepository.findAll(pageable).map(this::toResponse)); } - @GetMapping("/add") - public String addForm(Model model) { - model.addAttribute("notification", new Notification()); - model.addAttribute("users", userRepository.findAll()); - return "admin/notifications/form"; + @GetMapping("/{id}") + @Operation(summary = "알림 상세 조회") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(toResponse(findNotification(id))); } - @PostMapping("/add") - public String add(@ModelAttribute Notification notification, @RequestParam(required = false) Long userId) { - if (userId != null) { - userRepository.findById(userId).ifPresent(notification::setUser); - } - notificationRepository.save(notification); - return "redirect:/admin/notifications"; - } + @PostMapping + @Operation(summary = "알림 발송", description = "특정 사용자에게 알림을 생성합니다.") + @Transactional + public ResponseEntity create( + @Valid @RequestBody AdminNotificationCreateRequest request) { + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + request.getUserId())); - @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("notification", notificationRepository.findById(id).orElse(null)); - return "admin/notifications/detail"; + Notification notification = Notification.builder() + .user(user) + .notificationType(request.getNotificationType()) + .title(request.getTitle()) + .message(request.getMessage()) + .build(); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(toResponse(notificationRepository.save(notification))); } - @GetMapping("/{id}/edit") - public String editForm(@PathVariable Long id, Model model) { - model.addAttribute("notification", notificationRepository.findById(id).orElse(null)); - model.addAttribute("users", userRepository.findAll()); - return "admin/notifications/form"; + @DeleteMapping("/{id}") + @Operation(summary = "알림 삭제") + @Transactional + public ResponseEntity delete(@PathVariable Long id) { + notificationRepository.delete(findNotification(id)); + return ResponseEntity.noContent().build(); } - @PostMapping("/{id}/edit") - public String edit(@PathVariable Long id, @ModelAttribute Notification notification, @RequestParam(required = false) Long userId) { - notification.setId(id); - if (userId != null) { - userRepository.findById(userId).ifPresent(notification::setUser); - } - notificationRepository.save(notification); - return "redirect:/admin/notifications"; + private Notification findNotification(Long id) { + return notificationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("알림을 찾을 수 없습니다. ID: " + id)); } - @PostMapping("/{id}/delete") - public String delete(@PathVariable Long id) { - notificationRepository.deleteById(id); - return "redirect:/admin/notifications"; + private NotificationInfoResponse toResponse(Notification notification) { + return NotificationInfoResponse.builder() + .id(notification.getId()) + .userId(notification.getUser() != null ? notification.getUser().getUserId() : null) + .notificationType(notification.getNotificationType() != null + ? notification.getNotificationType().name() : null) + .title(notification.getTitle()) + .message(notification.getMessage()) + .isRead(Boolean.TRUE.equals(notification.getIsRead())) + .createdAt(notification.getCreatedAt()) + .build(); } } 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 2e33222d..0d34260a 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java @@ -1,58 +1,71 @@ package com.carecode.domain.admin.controller; +import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.admin.dto.AdminPolicyRequest; +import com.carecode.domain.admin.service.PolicyAdminService; +import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.mapper.PolicyMapper; import com.carecode.domain.policy.repository.PolicyRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@Controller -@RequestMapping("/admin/policies") +/** + * 어드민 정책 관리 API. + */ +@RestController +@RequestMapping("/api/admin/policies") @RequiredArgsConstructor +@Tag(name = "어드민 - 정책", description = "관리자 전용 정책 관리 API") public class AdminPolicyController { + private final PolicyRepository policyRepository; + private final PolicyMapper policyMapper; + private final PolicyAdminService policyAdminService; @GetMapping - public String list(Model model) { - model.addAttribute("policies", policyRepository.findAll()); - return "admin/policies/list"; - } - - @GetMapping("/add") - public String addForm(Model model) { - model.addAttribute("policy", new Policy()); - return "admin/policies/form"; + @Operation(summary = "정책 목록 조회") + public ResponseEntity> list( + @PageableDefault(size = 50, sort = "createdAt") Pageable pageable) { + return ResponseEntity.ok(policyRepository.findAll(pageable).map(policyMapper::toResponse)); } - @PostMapping("/add") - public String add(@ModelAttribute Policy policy) { - policyRepository.save(policy); - return "redirect:/admin/policies"; + @GetMapping("/{id}") + @Operation(summary = "정책 상세 조회") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(policyMapper.toResponse(findPolicy(id))); } - @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("policy", policyRepository.findById(id).orElse(null)); - return "admin/policies/detail"; + @PostMapping + @Operation(summary = "정책 등록", description = "재배포 없이 새 정책을 추가합니다.") + public ResponseEntity create(@Valid @RequestBody AdminPolicyRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(policyAdminService.create(request)); } - @GetMapping("/{id}/edit") - public String editForm(@PathVariable Long id, Model model) { - model.addAttribute("policy", policyRepository.findById(id).orElse(null)); - return "admin/policies/form"; + @PutMapping("/{id}") + @Operation(summary = "정책 수정", description = "수정 시 해당 정책의 캐시가 무효화됩니다.") + public ResponseEntity update(@PathVariable Long id, + @Valid @RequestBody AdminPolicyRequest request) { + return ResponseEntity.ok(policyAdminService.update(id, request)); } - @PostMapping("/{id}/edit") - public String edit(@PathVariable Long id, @ModelAttribute Policy policy) { - policy.setId(id); - policyRepository.save(policy); - return "redirect:/admin/policies"; + @DeleteMapping("/{id}") + @Operation(summary = "정책 삭제") + public ResponseEntity delete(@PathVariable Long id) { + policyAdminService.delete(id); + return ResponseEntity.noContent().build(); } - @PostMapping("/{id}/delete") - public String delete(@PathVariable Long id) { - policyRepository.deleteById(id); - return "redirect:/admin/policies"; + private Policy findPolicy(Long id) { + return policyRepository.findById(id) + .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + id)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java new file mode 100644 index 00000000..f2ed8dd0 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -0,0 +1,68 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; +import com.carecode.core.client.sync.SyncResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 공공데이터 수동 동기화 API. */ +@RestController +@RequestMapping("/api/admin/public-data") +@RequiredArgsConstructor +@Tag(name = "어드민 - 공공데이터", description = "공공데이터 수동 동기화 API") +public class AdminPublicDataController { + + private final NationwideChildcareFacilitySyncService facilitySyncService; + private final KindergartenSyncService kindergartenSyncService; + private final GovernmentBenefitSyncService benefitSyncService; + private final PediatricHospitalSyncService hospitalSyncService; + + @PostMapping("/facilities/sync") + @Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신") + public ResponseEntity> syncFacilities() { + return ResponseEntity.ok(toResponse(facilitySyncService.sync())); + } + + @PostMapping("/kindergartens/sync") + @Operation(summary = "전국 유치원 동기화", description = "유치원명·주소 기준으로 갱신") + public ResponseEntity> syncKindergartens() { + return ResponseEntity.ok(toResponse(kindergartenSyncService.sync())); + } + + @PostMapping("/benefits/sync") + @Operation(summary = "정부 지원 서비스 동기화", description = "육아 관련 서비스만 정책으로 갱신") + public ResponseEntity> syncBenefits() { + return ResponseEntity.ok(toResponse(benefitSyncService.sync())); + } + + @PostMapping("/hospitals/sync") + @Operation(summary = "소아청소년과 병원 동기화", description = "요양기호 기준으로 갱신") + public ResponseEntity> syncHospitals() { + return ResponseEntity.ok(toResponse(hospitalSyncService.sync())); + } + + 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; + } +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java b/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java new file mode 100644 index 00000000..1ce79551 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java @@ -0,0 +1,44 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.domain.community.dto.response.ReportResponse; +import com.carecode.domain.community.entity.Report; +import com.carecode.domain.community.service.ModerationService; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 어드민 신고 처리 API. + */ +@RestController +@RequestMapping("/api/admin/reports") +@RequiredArgsConstructor +@Tag(name = "어드민 - 신고 처리", description = "관리자 전용 커뮤니티 신고 처리 API") +public class AdminReportController { + + private final ModerationService moderationService; + + @GetMapping + @Operation(summary = "미처리 신고 목록 조회") + public ResponseEntity> pendingReports( + @PageableDefault(size = 50) Pageable pageable) { + return ResponseEntity.ok(moderationService.getPendingReports(pageable)); + } + + @PatchMapping("/{reportId}") + @Operation(summary = "신고 처리", + description = "ACCEPTED 로 처리하면 대상 게시글·댓글이 숨김 처리됩니다.") + public ResponseEntity resolve( + @PathVariable Long reportId, + @Parameter(description = "처리 결과 (ACCEPTED 또는 REJECTED)", required = true) + @RequestParam Report.ReportStatus status, + @Parameter(description = "처리 메모") @RequestParam(required = false) String note) { + return ResponseEntity.ok(moderationService.resolve(reportId, status, note)); + } +} 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 1013857f..8da7a1d8 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java @@ -1,58 +1,87 @@ package com.carecode.domain.admin.controller; +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -@Controller -@RequestMapping("/admin/users") +import java.time.LocalDateTime; + +/** + * 어드민 사용자 관리 API. + * + *

접근 제어는 SecurityConfig 의 {@code /api/admin/**} → hasRole("ADMIN") 규칙이 담당한다. + */ +@RestController +@RequestMapping("/api/admin/users") @RequiredArgsConstructor +@Tag(name = "어드민 - 사용자", description = "관리자 전용 사용자 관리 API") public class AdminUserController { + private final UserRepository userRepository; @GetMapping - public String list(Model model) { - model.addAttribute("users", userRepository.findAll()); - return "admin/users/list"; + @Operation(summary = "사용자 목록 조회") + public ResponseEntity> list( + @PageableDefault(size = 50, sort = "createdAt") Pageable pageable) { + return ResponseEntity.ok(userRepository.findAll(pageable).map(AdminUserResponse::from)); } - @GetMapping("/add") - public String addForm(Model model) { - model.addAttribute("user", new User()); - return "admin/users/form"; + @GetMapping("/{id}") + @Operation(summary = "사용자 상세 조회") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(AdminUserResponse.from(findUser(id))); } - @PostMapping("/add") - public String add(@ModelAttribute User user) { - userRepository.save(user); - return "redirect:/admin/users"; - } + @PatchMapping("/{id}") + @Operation(summary = "사용자 정보 수정", description = "이름·연락처·역할·활성 상태만 변경할 수 있습니다.") + @Transactional + public ResponseEntity update(@PathVariable Long id, + @Valid @RequestBody AdminUserUpdateRequest request) { + User user = findUser(id); - @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("user", userRepository.findById(id).orElse(null)); - return "admin/users/detail"; - } + 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()); - @GetMapping("/{id}/edit") - public String editForm(@PathVariable Long id, Model model) { - model.addAttribute("user", userRepository.findById(id).orElse(null)); - return "admin/users/form"; + return ResponseEntity.ok(AdminUserResponse.from(userRepository.save(user))); } - @PostMapping("/{id}/edit") - public String edit(@PathVariable Long id, @ModelAttribute User user) { - user.setId(id); + @DeleteMapping("/{id}") + @Operation(summary = "사용자 탈퇴 처리", description = "물리 삭제 대신 soft delete 로 비활성화합니다.") + @Transactional + public ResponseEntity delete(@PathVariable Long id) { + User user = findUser(id); + // 하드 삭제하면 게시글·건강기록 등 참조 데이터가 함께 사라지므로 soft delete 로 처리한다. + user.setDeletedAt(LocalDateTime.now()); + user.setIsActive(false); userRepository.save(user); - return "redirect:/admin/users"; + return ResponseEntity.noContent().build(); } - @PostMapping("/{id}/delete") - public String delete(@PathVariable Long id) { - userRepository.deleteById(id); - return "redirect:/admin/users"; + private User findUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + id)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java new file mode 100644 index 00000000..24666555 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java @@ -0,0 +1,30 @@ +package com.carecode.domain.admin.dto; + +import com.carecode.domain.notification.entity.Notification; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 어드민 알림 발송 요청. + *

엔티티를 직접 바인딩하지 않고 허용 필드만 받는다. + */ +@Getter +@Setter +@NoArgsConstructor +public class AdminNotificationCreateRequest { + + @NotNull(message = "대상 사용자 ID는 필수입니다") + private Long userId; + + @NotNull(message = "알림 유형은 필수입니다") + private Notification.NotificationType notificationType; + + @NotBlank(message = "제목은 필수입니다") + private String title; + + @NotBlank(message = "내용은 필수입니다") + private String message; +} diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java new file mode 100644 index 00000000..d172b48e --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java @@ -0,0 +1,67 @@ +package com.carecode.domain.admin.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 관리자 정책 생성·수정 요청. + * + *

정책은 매년 바뀌는데 코드에 하드코딩돼 있어 재배포 없이는 수정할 수 없었다. + * 이 API 로 운영 중 관리할 수 있게 한다. + */ +@Getter +@Setter +@NoArgsConstructor +public class AdminPolicyRequest { + + @NotBlank(message = "정책 코드는 필수입니다") + @Size(max = 50) + private String policyCode; + + @NotBlank(message = "정책명은 필수입니다") + @Size(max = 200) + private String title; + + @Size(max = 2000) + private String description; + + @Size(max = 50) + private String policyType; + + private Integer targetAgeMin; + private Integer targetAgeMax; + + @Size(max = 100) + private String targetRegion; + + private Integer benefitAmount; + + @Size(max = 50) + private String benefitType; + + private LocalDate applicationStartDate; + private LocalDate applicationEndDate; + private LocalDate policyStartDate; + private LocalDate policyEndDate; + + @Size(max = 500) + private String applicationUrl; + + @Size(max = 200) + private String contactInfo; + + @Size(max = 1000) + private String requiredDocuments; + + private Boolean isActive; + + private Integer priority; + + /** 연결할 정책 카테고리 ID. */ + private Long policyCategoryId; +} diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java new file mode 100644 index 00000000..ad936c41 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java @@ -0,0 +1,44 @@ +package com.carecode.domain.admin.dto; + +import com.carecode.domain.user.entity.User; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 어드민 사용자 목록/상세 응답. + *

비밀번호 해시, OAuth provider ID 등 밖으로 나가면 안 되는 값은 담지 않는다. + */ +@Getter +@Builder +public class AdminUserResponse { + + private final Long id; + private final String userId; + private final String email; + private final String name; + private final String phoneNumber; + private final String role; + private final Boolean isActive; + private final Boolean emailVerified; + private final LocalDateTime lastLoginAt; + private final LocalDateTime createdAt; + private final LocalDateTime deletedAt; + + public static AdminUserResponse from(User user) { + return AdminUserResponse.builder() + .id(user.getId()) + .userId(user.getUserId()) + .email(user.getEmail()) + .name(user.getName()) + .phoneNumber(user.getPhoneNumber()) + .role(user.getRole() != null ? user.getRole().name() : null) + .isActive(user.getIsActive()) + .emailVerified(user.getEmailVerified()) + .lastLoginAt(user.getLastLoginAt()) + .createdAt(user.getCreatedAt()) + .deletedAt(user.getDeletedAt()) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java new file mode 100644 index 00000000..f09ff0bb --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java @@ -0,0 +1,23 @@ +package com.carecode.domain.admin.dto; + +import com.carecode.domain.user.entity.UserRole; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 어드민이 변경할 수 있는 사용자 속성만 명시한 요청 객체. + * + *

엔티티를 그대로 바인딩하면(과거 {@code @ModelAttribute User}) 비밀번호 해시나 + * 임의 필드까지 덮어쓸 수 있는 mass assignment 가 된다. 허용 필드를 화이트리스트로 고정한다. + */ +@Getter +@Setter +@NoArgsConstructor +public class AdminUserUpdateRequest { + + private String name; + private String phoneNumber; + private UserRole role; + private Boolean isActive; +} diff --git a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java new file mode 100644 index 00000000..b8034b22 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java @@ -0,0 +1,97 @@ +package com.carecode.domain.admin.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.admin.dto.AdminPolicyRequest; +import com.carecode.domain.policy.dto.response.PolicyDto; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyCategory; +import com.carecode.domain.policy.mapper.PolicyMapper; +import com.carecode.domain.policy.repository.PolicyCategoryRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 관리자 정책 관리. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyAdminService { + + private final PolicyRepository policyRepository; + private final PolicyCategoryRepository policyCategoryRepository; + private final PolicyMapper policyMapper; + + @Transactional + public PolicyDto create(AdminPolicyRequest request) { + policyRepository.findByPolicyCode(request.getPolicyCode()).ifPresent(existing -> { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "이미 존재하는 정책 코드입니다: " + request.getPolicyCode()); + }); + + Policy policy = new Policy(); + apply(policy, request); + policy.setViewCount(0); + + Policy saved = policyRepository.save(policy); + log.info("정책 생성 - policyId={}, code={}", saved.getId(), saved.getPolicyCode()); + return policyMapper.toResponse(saved); + } + + /** + * 정책 수정. + * 캐시된 상세 응답이 낡지 않도록 해당 항목을 무효화한다. + */ + @Transactional + @CacheEvict(cacheNames = "policy", key = "#policyId") + public PolicyDto update(Long policyId, AdminPolicyRequest request) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + policyId)); + + apply(policy, request); + return policyMapper.toResponse(policyRepository.save(policy)); + } + + @Transactional + @CacheEvict(cacheNames = "policy", key = "#policyId") + public void delete(Long policyId) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + policyId)); + policyRepository.delete(policy); + } + + private void apply(Policy policy, AdminPolicyRequest request) { + policy.setPolicyCode(request.getPolicyCode()); + policy.setTitle(request.getTitle()); + policy.setDescription(request.getDescription()); + policy.setPolicyType(request.getPolicyType()); + policy.setTargetAgeMin(request.getTargetAgeMin()); + policy.setTargetAgeMax(request.getTargetAgeMax()); + policy.setTargetRegion(request.getTargetRegion()); + policy.setBenefitAmount(request.getBenefitAmount()); + policy.setBenefitType(request.getBenefitType()); + policy.setApplicationStartDate(request.getApplicationStartDate()); + policy.setApplicationEndDate(request.getApplicationEndDate()); + policy.setPolicyStartDate(request.getPolicyStartDate()); + policy.setPolicyEndDate(request.getPolicyEndDate()); + policy.setApplicationUrl(request.getApplicationUrl()); + policy.setContactInfo(request.getContactInfo()); + policy.setRequiredDocuments(request.getRequiredDocuments()); + policy.setIsActive(request.getIsActive() != null ? request.getIsActive() : Boolean.TRUE); + policy.setPriority(request.getPriority()); + + if (request.getPolicyCategoryId() != null) { + PolicyCategory category = policyCategoryRepository.findById(request.getPolicyCategoryId()) + .orElseThrow(() -> new BusinessException(ErrorCode.POLICY_CATEGORY_NOT_FOUND, + "정책 카테고리를 찾을 수 없습니다: " + request.getPolicyCategoryId())); + policy.setPolicyCategory(category); + } + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java index 7035eb59..e478f7b1 100644 --- a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java @@ -27,8 +27,8 @@ public class CareFacilityFacade { private final CareFacilityBookingService bookingService; @Transactional(readOnly = true) - public List getAllCareFacilities() { - return careFacilityService.getAllCareFacilities(); + public List getAllCareFacilities(int page, int size) { + return careFacilityService.getAllCareFacilities(page, size); } @Transactional(readOnly = true) diff --git a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java index d80f805f..6b01f548 100644 --- a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java @@ -4,6 +4,7 @@ import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.annotation.ValidateChildAge; import com.carecode.core.controller.BaseController; +import com.carecode.core.util.PageRequestUtil; import com.carecode.domain.careFacility.dto.request.CareFacilitySearchRequest; import com.carecode.domain.careFacility.dto.request.CareFacilityAdvancedSearchRequest; import com.carecode.domain.careFacility.dto.request.ReviewRequest; @@ -47,9 +48,12 @@ public class CareFacilityController extends BaseController { @GetMapping @LogExecutionTime @Operation(summary = "전체 시설 목록 조회", description = "등록된 모든 육아 시설 목록을 조회합니다.") - public ResponseEntity> getAllFacilities() { + public ResponseEntity> getAllFacilities( + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(required = false) Integer page, + @Parameter(description = "페이지 크기 (최대 200)") @RequestParam(required = false) Integer size) { - List facilities = careFacilityFacade.getAllCareFacilities(); + List facilities = careFacilityFacade.getAllCareFacilities( + PageRequestUtil.normalizePage(page), PageRequestUtil.normalizeSize(size)); return ResponseEntity.ok(facilities); } diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java index 097897b4..a991814b 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java @@ -3,7 +3,9 @@ import com.carecode.domain.careFacility.entity.CareFacilityBooking; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -30,6 +32,27 @@ List findByFacilityIdAndStartTimeBetween(@Param("facilityId @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); + /** + * 주어진 구간과 실제로 겹치는 유효 예약 수. + * + *

겹침 판정은 {@code 기존.start < 신규.end AND 기존.end > 신규.start} 이다. + * 시작 시각만 비교하면 09:00~18:00 종일 예약이 있어도 11:00 예약이 통과된다. + * 취소/거절된 예약은 자리를 차지하지 않으므로 제외한다. + * + * @param excludeBookingId 예약 수정 시 자기 자신을 충돌로 세지 않기 위한 제외 ID (신규 생성 시 null) + */ + // PESSIMISTIC_WRITE: 검사와 저장 사이에 다른 트랜잭션이 같은 구간을 예약하는 경쟁 조건을 막는다. + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT COUNT(cb) FROM CareFacilityBooking cb " + + "WHERE cb.facility.id = :facilityId " + + "AND cb.status <> com.carecode.domain.careFacility.entity.CareFacilityBooking.BookingStatus.CANCELLED " + + "AND (:excludeBookingId IS NULL OR cb.id <> :excludeBookingId) " + + "AND cb.startTime < :newEnd AND cb.endTime > :newStart") + long countOverlappingBookings(@Param("facilityId") Long facilityId, + @Param("newStart") LocalDateTime newStart, + @Param("newEnd") LocalDateTime newEnd, + @Param("excludeBookingId") Long excludeBookingId); + // 상태별 예약 수 조회 long countByStatus(CareFacilityBooking.BookingStatus status); @@ -37,16 +60,41 @@ List findByFacilityIdAndStartTimeBetween(@Param("facilityId long countByBookingType(CareFacilityBooking.BookingType bookingType); // 오늘 예약 목록 조회 - @Query("SELECT cb FROM CareFacilityBooking cb WHERE DATE(cb.startTime) = CURRENT_DATE ORDER BY cb.startTime ASC") - List findTodayBookings(); + // 주의: HQL 에 DATE(...) 함수는 없다. Hibernate 6 에서는 파싱 단계에서 실패해 + // 리포지토리 빈 생성이 깨지고 애플리케이션이 기동되지 않는다. + // 범위 비교로 바꾸면 startTime 인덱스도 그대로 탈 수 있다. + @Query("SELECT cb FROM CareFacilityBooking cb " + + "WHERE cb.startTime >= :dayStart AND cb.startTime < :dayEnd ORDER BY cb.startTime ASC") + List findBookingsBetween(@Param("dayStart") LocalDateTime dayStart, + @Param("dayEnd") LocalDateTime dayEnd); // 시설별 오늘 예약 목록 조회 - @Query("SELECT cb FROM CareFacilityBooking cb WHERE cb.facility.id = :facilityId AND DATE(cb.startTime) = CURRENT_DATE ORDER BY cb.startTime ASC") - List findTodayBookingsByFacility(@Param("facilityId") Long facilityId); + @Query("SELECT cb FROM CareFacilityBooking cb WHERE cb.facility.id = :facilityId " + + "AND cb.startTime >= :dayStart AND cb.startTime < :dayEnd ORDER BY cb.startTime ASC") + List findBookingsByFacilityBetween(@Param("facilityId") Long facilityId, + @Param("dayStart") LocalDateTime dayStart, + @Param("dayEnd") LocalDateTime dayEnd); // 오늘 예약 수 조회 - @Query("SELECT COUNT(cb) FROM CareFacilityBooking cb WHERE DATE(cb.startTime) = CURRENT_DATE") - long countTodayBookings(); + @Query("SELECT COUNT(cb) FROM CareFacilityBooking cb " + + "WHERE cb.startTime >= :dayStart AND cb.startTime < :dayEnd") + long countBookingsBetween(@Param("dayStart") LocalDateTime dayStart, + @Param("dayEnd") LocalDateTime dayEnd); + + default List findTodayBookings() { + LocalDate today = LocalDate.now(); + return findBookingsBetween(today.atStartOfDay(), today.plusDays(1).atStartOfDay()); + } + + default List findTodayBookingsByFacility(Long facilityId) { + LocalDate today = LocalDate.now(); + return findBookingsByFacilityBetween(facilityId, today.atStartOfDay(), today.plusDays(1).atStartOfDay()); + } + + default long countTodayBookings() { + LocalDate today = LocalDate.now(); + return countBookingsBetween(today.atStartOfDay(), today.plusDays(1).atStartOfDay()); + } // 이번 주 예약 수 조회 @Query("SELECT COUNT(cb) FROM CareFacilityBooking cb WHERE cb.startTime >= :weekStart AND cb.startTime <= :weekEnd") @@ -81,8 +129,11 @@ Page findBySearchCriteria(@Param("facilityId") Long facilit List getFacilityBookingStats(); // 일별 예약 수 통계 - @Query("SELECT DATE(cb.startTime), COUNT(cb) FROM CareFacilityBooking cb WHERE cb.startTime >= :startDate GROUP BY DATE(cb.startTime) ORDER BY DATE(cb.startTime) DESC") - List getDailyBookingCounts(@Param("startDate") LocalDate startDate); + // DATE(...) 대신 HQL 표준인 cast(... as date) 를 쓴다. 파라미터 타입도 비교 대상과 맞춘다. + @Query("SELECT cast(cb.startTime as date), COUNT(cb) FROM CareFacilityBooking cb " + + "WHERE cb.startTime >= :startDate " + + "GROUP BY cast(cb.startTime as date) ORDER BY cast(cb.startTime as date) DESC") + List getDailyBookingCounts(@Param("startDate") LocalDateTime startDate); // 이번 주 예약 수 조회 (기본값) default long countThisWeekBookings() { @@ -102,7 +153,6 @@ default long countThisMonthBookings() { // 일별 예약 수 조회 (기본값 - 최근 30일) default List getDailyBookingCounts() { - LocalDate startDate = LocalDate.now().minusDays(30); - return getDailyBookingCounts(startDate); + return getDailyBookingCounts(LocalDate.now().minusDays(30).atStartOfDay()); } } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java index b4e4b287..4965e157 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -123,18 +124,35 @@ List searchFacilities(@Param("facilityType") FacilityType facility List findNewFacilities(org.springframework.data.domain.Pageable pageable); - // 반경 내 시설 조회 - @Query(value = "SELECT cf.*, " + - "(6371 * acos(cos(radians(:latitude)) * cos(radians(cf.latitude)) * " + - "cos(radians(cf.longitude) - radians(:longitude)) + " + - "sin(radians(:latitude)) * sin(radians(cf.latitude)))) AS distance " + - "FROM care_facilities cf " + - "WHERE cf.is_active = true " + - "HAVING distance <= :radius " + - "ORDER BY distance", nativeQuery = true) - List findWithinRadius(@Param("latitude") Double latitude, - @Param("longitude") Double longitude, - @Param("radius") Double radius); + /** 반경 내 시설 조회. 바운딩 박스로 후보를 좁힌 뒤 정확한 거리를 계산한다. */ + @Query(value = "SELECT cf.* FROM (" + + " SELECT c.*, (6371 * acos(LEAST(1, " + + " cos(radians(:latitude)) * cos(radians(c.LATITUDE)) * " + + " cos(radians(c.LONGITUDE) - radians(:longitude)) + " + + " sin(radians(:latitude)) * sin(radians(c.LATITUDE))))) AS distance " + + " FROM TBL_CARE_FACILITIES c " + + " WHERE c.IS_ACTIVE = true " + + " AND c.LATITUDE BETWEEN :minLat AND :maxLat " + + " AND c.LONGITUDE BETWEEN :minLng AND :maxLng" + + ") cf WHERE cf.distance <= :radius ORDER BY cf.distance", + nativeQuery = true) + List findWithinBoundingBox(@Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("radius") double radius, + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLng") double minLng, + @Param("maxLng") double maxLng); + + /** 전문 검색. LIKE '%키워드%' 와 달리 인덱스를 타고 관련도 순으로 정렬된다. */ + @Query(value = "SELECT * FROM TBL_CARE_FACILITIES " + + "WHERE IS_ACTIVE = true " + + "AND MATCH(NAME, ADDRESS) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", + countQuery = "SELECT COUNT(*) FROM TBL_CARE_FACILITIES " + + "WHERE IS_ACTIVE = true " + + "AND MATCH(NAME, ADDRESS) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", + nativeQuery = true) + Page searchByFullText(@Param("keyword") String keyword, Pageable pageable); // 검색 조건으로 시설 검색 (페이징) @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + @@ -149,4 +167,11 @@ Page findBySearchCriteria(@Param("keyword") String keyword, // ID로 시설 조회 (Reviews와 함께) @Query("SELECT cf FROM CareFacility cf LEFT JOIN FETCH cf.reviews WHERE cf.id = :id") Optional findByIdWithReviews(@Param("id") Long id); + + /** + * 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE CareFacility cf SET cf.viewCount = COALESCE(cf.viewCount, 0) + 1 WHERE cf.id = :facilityId") + int incrementViewCount(@Param("facilityId") Long facilityId); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java index cf1a8a53..f4817d4d 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java @@ -34,6 +34,9 @@ @Transactional(readOnly = true) public class CareFacilityBookingService { + /** 시설에 정원 정보가 없을 때 적용할 동시 예약 허용 건수. */ + private static final int DEFAULT_CONCURRENT_BOOKING_LIMIT = 1; + private final CareFacilityBookingRepository bookingRepository; private final CareFacilityRepository careFacilityRepository; private final UserRepository userRepository; @@ -53,7 +56,7 @@ public BookingResponse createBooking(Long facilityId, CreateBookingRequest reque .orElseThrow(() -> new CareServiceException("사용자를 찾을 수 없습니다: " + userDetails.getUsername())); // 예약 시간 중복 확인 - validateBookingTime(facilityId, request.getStartTime()); + validateBookingTime(careFacility, request.getStartTime(), request.getEndTime(), null); // 예약 생성 CareFacilityBooking booking = new CareFacilityBooking( @@ -200,9 +203,11 @@ public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest reques throw new CareServiceException("완료된 예약은 수정할 수 없습니다."); } - // 예약 시간 변경 시 중복 확인 - if (!booking.getStartTime().equals(request.getStartTime())) { - validateBookingTime(booking.getFacility().getId(), request.getStartTime()); + // 예약 시간이 바뀌면 다시 검증한다. 종료 시각만 바뀌어도 겹침 여부가 달라지므로 둘 다 비교한다. + boolean timeChanged = !booking.getStartTime().equals(request.getStartTime()) + || !java.util.Objects.equals(booking.getEndTime(), request.getEndTime()); + if (timeChanged) { + validateBookingTime(booking.getFacility(), request.getStartTime(), request.getEndTime(), booking.getId()); } // 예약 정보 업데이트 @@ -247,14 +252,36 @@ public List getTodayBookingsByFacility(Long facilityId) { // 예약 시간 중복 확인 - private void validateBookingTime(Long facilityId, LocalDateTime scheduledDateTime) { - LocalDateTime startTime = scheduledDateTime.minusHours(1); - LocalDateTime endTime = scheduledDateTime.plusHours(1); - - List conflictingBookings = bookingRepository.findByFacilityIdAndStartTimeBetween(facilityId, startTime, endTime); - - if (!conflictingBookings.isEmpty()) { - throw new CareServiceException("해당 시간에 이미 예약이 있습니다. 다른 시간을 선택해주세요."); + /** + * 예약 가능 여부 검증. + * + *

이전 구현은 시작 시각 ±1시간만 비교해서 (1) 기존 예약의 종료 시각을 무시했고, + * (2) 취소된 예약도 충돌로 셌으며, (3) 시설 정원과 무관하게 1건만 있어도 막았다. + * 여기서는 실제 구간 겹침을 보고, 겹치는 유효 예약 수가 정원 미만일 때만 허용한다. + */ + private void validateBookingTime(CareFacility facility, + LocalDateTime startTime, + LocalDateTime endTime, + Long excludeBookingId) { + if (startTime == null || endTime == null) { + throw new CareServiceException("예약 시작/종료 시간은 필수입니다."); + } + if (!endTime.isAfter(startTime)) { + throw new CareServiceException("예약 종료 시간은 시작 시간보다 뒤여야 합니다."); + } + if (startTime.isBefore(LocalDateTime.now())) { + throw new CareServiceException("과거 시간으로는 예약할 수 없습니다."); + } + + long overlapping = bookingRepository.countOverlappingBookings( + facility.getId(), startTime, endTime, excludeBookingId); + + int capacity = facility.getCapacity() != null && facility.getCapacity() > 0 + ? facility.getCapacity() + : DEFAULT_CONCURRENT_BOOKING_LIMIT; + + if (overlapping >= capacity) { + throw new CareServiceException("해당 시간에 예약 가능한 자리가 없습니다. 다른 시간을 선택해주세요."); } } diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index 03594c95..714c2415 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -1,7 +1,9 @@ package com.carecode.domain.careFacility.service; -import com.carecode.core.annotation.CacheableResult; +import org.springframework.cache.annotation.Cacheable; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.util.BoundingBox; +import com.carecode.core.search.FullTextSearchSupport; import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.exception.CareFacilityNotFoundException; import com.carecode.domain.careFacility.dto.request.CareFacilitySearchRequest; @@ -49,6 +51,7 @@ public class CareFacilityService { private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final CareFacilityMapper careFacilityMapper; + private final FullTextSearchSupport fullTextSearchSupport; // 공공데이터 API에서 받아온 보육시설 데이터를 DB에 저장 @@ -227,20 +230,31 @@ private String generateDescription(Map facilityData) { // 돌봄 시설 목록 조회 + /** + * 시설 목록 조회. + *

테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. + */ @LogExecutionTime - public List getAllCareFacilities() { + public List getAllCareFacilities(int page, int size) { - List facilities = careFacilityRepository.findAll(); - return facilities.stream() + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "name")); + return careFacilityRepository.findAll(pageable).getContent().stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); } + // 전체 시설 수 + + public long countCareFacilities() { + return careFacilityRepository.count(); + } + + // 돌봄 시설 상세 조회 @LogExecutionTime - @CacheableResult(cacheName = "careFacility", key = "#facilityId") + @Cacheable(cacheNames = "careFacility", key = "#facilityId") public CareFacilityInfo getCareFacilityById(Long facilityId) { CareFacility facility = careFacilityRepository.findById(facilityId) .orElseThrow(() -> new CareFacilityNotFoundException("돌봄 시설을 찾을 수 없습니다: " + facilityId)); @@ -261,14 +275,20 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r Sort.Direction.ASC ); Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort); - - Page facilityPage = careFacilityRepository.findBySearchCriteria( - request.getKeyword(), - null, - request.getCity(), - pageable - ); - + + // 키워드만 있는 검색은 전문 검색으로 처리한다. LIKE '%키워드%' 는 인덱스를 못 탄다. + boolean keywordOnly = request.getCity() == null || request.getCity().isBlank(); + Page facilityPage; + if (keywordOnly && fullTextSearchSupport.canUseFullText(request.getKeyword())) { + String normalized = fullTextSearchSupport.normalize(request.getKeyword()); + facilityPage = careFacilityRepository.searchByFullText(normalized, + PageRequest.of(request.getPage(), request.getSize())); + } else { + facilityPage = careFacilityRepository.findBySearchCriteria( + request.getKeyword(), null, request.getCity(), pageable); + } + + List facilities = facilityPage.getContent().stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); @@ -312,7 +332,10 @@ public List getCareFacilitiesByLocation(String location) { @LogExecutionTime @ValidateLocation public List getCareFacilitiesWithinRadius(Double latitude, Double longitude, Double radius) { - List facilities = careFacilityRepository.findWithinRadius(latitude, longitude, radius); + BoundingBox box = BoundingBox.around(latitude, longitude, radius); + List facilities = careFacilityRepository.findWithinBoundingBox( + latitude, longitude, radius, + box.minLat(), box.maxLat(), box.minLng(), box.maxLng()); return facilities.stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); @@ -369,12 +392,11 @@ public List getNewCareFacilities(int limit) { @Transactional public void incrementViewCount(Long facilityId) { - CareFacility facility = careFacilityRepository.findById(facilityId) - .orElseThrow(() -> new CareFacilityNotFoundException("돌봄 시설을 찾을 수 없습니다: " + facilityId)); - - Integer currentViewCount = facility.getViewCount() != null ? facility.getViewCount() : 0; - facility.setViewCount(currentViewCount + 1); - careFacilityRepository.save(facility); + // DB 에서 원자적으로 증가시킨다. 갱신된 행이 없으면 존재하지 않는 시설이다. + int updated = careFacilityRepository.incrementViewCount(facilityId); + if (updated == 0) { + throw new CareFacilityNotFoundException("돌봄 시설을 찾을 수 없습니다: " + facilityId); + } } diff --git a/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java b/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java new file mode 100644 index 00000000..aa074a5a --- /dev/null +++ b/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java @@ -0,0 +1,23 @@ +package com.carecode.domain.chatbot.llm; + +import com.carecode.domain.chatbot.rag.RetrievedContext; + +import java.util.Optional; + +/** + * 챗봇 응답 생성기. + * + *

구현체를 바꾸면 LLM 공급자를 교체할 수 있다. + * 사용 불가 상태({@link #isAvailable()} == false)면 호출부가 룰 기반 응답으로 폴백한다. + */ +public interface ChatCompletionClient { + + boolean isAvailable(); + + /** + * 검색된 근거를 바탕으로 답변을 생성한다. + * + * @return 생성 실패 시 {@link Optional#empty()} — 예외를 던지지 않는다 + */ + Optional generateReply(String userMessage, RetrievedContext context); +} diff --git a/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java b/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java new file mode 100644 index 00000000..6c791f8e --- /dev/null +++ b/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java @@ -0,0 +1,115 @@ +package com.carecode.domain.chatbot.llm; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.carecode.domain.chatbot.rag.RetrievedContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Claude API 기반 챗봇 응답 생성기. + * + *

API 키가 없으면 비활성 상태로 동작하고, 호출부가 기존 룰 기반 응답으로 폴백한다. + * 로컬/CI 환경에서 키 없이도 애플리케이션이 뜨도록 하기 위함이다. + */ +@Slf4j +@Component +public class ClaudeChatCompletionClient implements ChatCompletionClient { + + private static final String SYSTEM_PROMPT = """ + 당신은 육아 지원 플랫폼 '맘편한'의 상담 도우미입니다. + + 답변 규칙: + - 아래 <참고자료>에 있는 내용을 우선 근거로 삼아 답하세요. + - 참고자료에 없는 내용은 지어내지 말고, 모른다고 말한 뒤 관련 메뉴를 안내하세요. + - 의료적 판단(진단, 투약, 응급 여부)은 하지 말고 전문의 상담을 권하세요. + - 한국어로, 3~5문장 이내로 간결하게 답하세요. + - 정책의 금액이나 신청 방법을 말할 때는 참고자료의 값을 그대로 인용하세요. + """; + + private final AnthropicClient client; + private final String model; + private final long maxTokens; + + public ClaudeChatCompletionClient( + @Value("${app.chatbot.claude.api-key:}") String apiKey, + @Value("${app.chatbot.claude.model:claude-opus-5}") String model, + @Value("${app.chatbot.claude.max-tokens:1024}") long maxTokens) { + + this.model = model; + this.maxTokens = maxTokens; + + if (apiKey == null || apiKey.isBlank()) { + log.info("Claude API 키가 설정되지 않아 챗봇이 룰 기반 응답으로 동작합니다. " + + "(app.chatbot.claude.api-key)"); + this.client = null; + } else { + this.client = AnthropicOkHttpClient.builder().apiKey(apiKey).build(); + log.info("Claude 챗봇 활성화 - model={}", model); + } + } + + @Override + public boolean isAvailable() { + return client != null; + } + + @Override + public Optional generateReply(String userMessage, RetrievedContext context) { + if (!isAvailable()) { + return Optional.empty(); + } + + try { + MessageCreateParams params = MessageCreateParams.builder() + .model(model) + .maxTokens(maxTokens) + .system(SYSTEM_PROMPT) + .addUserMessage(buildUserPrompt(userMessage, context)) + .build(); + + Message response = client.messages().create(params); + + String text = response.content().stream() + .flatMap(block -> block.text().stream()) + .map(textBlock -> textBlock.text()) + .collect(Collectors.joining("\n")) + .trim(); + + return text.isEmpty() ? Optional.empty() : Optional.of(text); + } catch (Exception e) { + // LLM 장애가 챗봇 전체를 막지 않도록 폴백을 허용한다. + log.error("Claude 응답 생성 실패", e); + return Optional.empty(); + } + } + + /** + * 검색된 근거를 프롬프트에 넣는다. + * 근거가 없으면 그 사실을 명시해 모델이 없는 정보를 지어내지 않게 한다. + */ + private String buildUserPrompt(String userMessage, RetrievedContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("<참고자료>\n"); + + if (context == null || context.isEmpty()) { + sb.append("(관련 등록 정보를 찾지 못했습니다. 일반적인 안내만 하고, 구체적인 정책명이나 시설명은 언급하지 마세요.)\n"); + } else { + for (RetrievedContext.Snippet snippet : context.getSnippets()) { + sb.append("- [").append(snippet.getSource()).append("] ") + .append(snippet.getTitle()).append(": ") + .append(snippet.getContent()).append('\n'); + } + } + + sb.append("\n\n질문: ").append(userMessage); + return sb.toString(); + } +} diff --git a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java new file mode 100644 index 00000000..07d2ac7f --- /dev/null +++ b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java @@ -0,0 +1,147 @@ +package com.carecode.domain.chatbot.rag; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +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.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** 챗봇 답변 근거 검색기(RAG 의 R). 임베딩 검색으로 바꾸려면 이 클래스 내부만 교체하면 된다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CareKnowledgeRetriever { + + private static final int MAX_PER_SOURCE = 3; + private static final int MAX_CONTENT_LENGTH = 400; + private static final int MAX_KEYWORDS = 3; + + /** 검색어에서 제외할 의문사·범용어. 이걸로 검색하면 아무거나 매칭된다. */ + private static final List STOP_WORDS = List.of( + "알려줘", "알려주세요", "어디", "무엇", "뭐가", "뭔가요", "있나요", "있어", + "해줘", "하고", "그리고", "관련", "대해", "대한", "정보", "추천", + "우리", "저희", "지금", "가까운", "근처"); + + /** 길이 내림차순. 긴 것부터 떼어내야 "에서" 가 "서" 로 잘리지 않는다. */ + private static final List JOSA = List.of( + "에서는", "으로는", "에게는", "에서", "으로", "에게", "부터", "까지", "이랑", + "은", "는", "이", "가", "을", "를", "의", "에", "도", "와", "과", "로"); + + private final PolicyRepository policyRepository; + private final CareFacilityRepository careFacilityRepository; + + @Transactional(readOnly = true) + public RetrievedContext retrieve(String question) { + List keywords = extractKeywords(question); + if (keywords.isEmpty()) { + return RetrievedContext.builder().snippets(List.of()).build(); + } + + // 변별력 높은 키워드부터 시도하고, 근거를 찾으면 멈춘다. + List snippets = new ArrayList<>(); + for (String keyword : keywords) { + try { + snippets.addAll(searchPolicies(keyword)); + snippets.addAll(searchFacilities(keyword)); + } catch (Exception e) { + // 검색 실패가 대화를 막지 않도록 한다. 근거 없이 일반 답변으로 진행한다. + log.error("챗봇 근거 검색 실패 - keyword={}", keyword, e); + } + if (!snippets.isEmpty()) { + break; + } + } + + return RetrievedContext.builder().snippets(snippets).build(); + } + + private List searchPolicies(String keyword) { + List policies = policyRepository + .findBySearchCriteria(keyword, null, null, null, null, PageRequest.of(0, MAX_PER_SOURCE)) + .getContent(); + + return policies.stream() + .map(p -> RetrievedContext.Snippet.builder() + .source("정책") + .title(p.getTitle()) + .content(buildPolicyContent(p)) + .build()) + .toList(); + } + + private List searchFacilities(String keyword) { + List facilities = careFacilityRepository + .findBySearchCriteria(keyword, null, null, PageRequest.of(0, MAX_PER_SOURCE)) + .getContent(); + + return facilities.stream() + .map(f -> RetrievedContext.Snippet.builder() + .source("돌봄시설") + .title(f.getName()) + .content(truncate(String.format("주소: %s / 연락처: %s / 유형: %s", + f.getAddress(), f.getPhone(), f.getFacilityType()))) + .build()) + .toList(); + } + + private String buildPolicyContent(Policy policy) { + StringBuilder sb = new StringBuilder(); + if (policy.getDescription() != null) { + sb.append(policy.getDescription()); + } + if (policy.getBenefitAmount() != null) { + sb.append(" / 지원금액: ").append(policy.getBenefitAmount()); + } + if (policy.getApplicationUrl() != null) { + sb.append(" / 신청: ").append(policy.getApplicationUrl()); + } + return truncate(sb.toString()); + } + + /** 질문에서 검색어 후보를 길이 내림차순으로 뽑는다. 긴 토큰일수록 변별력이 높다. */ + List extractKeywords(String question) { + if (question == null || question.isBlank()) { + return List.of(); + } + + String cleaned = question.replaceAll("[^가-힣a-zA-Z0-9\\s]", " "); + List keywords = new ArrayList<>(); + for (String token : cleaned.split("\\s+")) { + String normalized = stripJosa(token); + if (normalized.length() < 2 || STOP_WORDS.contains(normalized) || keywords.contains(normalized)) { + continue; + } + keywords.add(normalized); + } + keywords.sort(Comparator.comparingInt(String::length).reversed()); + return keywords.size() > MAX_KEYWORDS ? keywords.subList(0, MAX_KEYWORDS) : keywords; + } + + /** + * 흔한 조사를 떼어낸다. 형태소 분석기 없이 "어린이집은" → "어린이집" 정도만 맞춘다. + * 조사를 떼면 2자 미만이 되는 짧은 단어는 원형을 유지한다. + */ + private String stripJosa(String token) { + for (String josa : JOSA) { + if (token.length() > josa.length() + 1 && token.endsWith(josa)) { + return token.substring(0, token.length() - josa.length()); + } + } + return token; + } + + private String truncate(String text) { + if (text == null) { + return ""; + } + return text.length() <= MAX_CONTENT_LENGTH ? text : text.substring(0, MAX_CONTENT_LENGTH) + "..."; + } +} diff --git a/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java b/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java new file mode 100644 index 00000000..95f49458 --- /dev/null +++ b/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java @@ -0,0 +1,30 @@ +package com.carecode.domain.chatbot.rag; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** + * 챗봇 답변의 근거로 사용할 검색 결과. + */ +@Getter +@Builder +public class RetrievedContext { + + /** 프롬프트에 넣을 문서 조각. */ + private final List snippets; + + public boolean isEmpty() { + return snippets == null || snippets.isEmpty(); + } + + @Getter + @Builder + public static class Snippet { + /** 출처 종류 (정책/시설/병원 등). */ + private final String source; + private final String title; + private final String content; + } +} diff --git a/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java b/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java index 7a32a7b4..3073a2a8 100644 --- a/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java @@ -10,6 +10,9 @@ import com.carecode.domain.chatbot.entity.ChatSession; import com.carecode.domain.chatbot.repository.ChatMessageRepository; import com.carecode.domain.chatbot.repository.ChatSessionRepository; +import com.carecode.domain.chatbot.llm.ChatCompletionClient; +import com.carecode.domain.chatbot.rag.CareKnowledgeRetriever; +import com.carecode.domain.chatbot.rag.RetrievedContext; import com.carecode.domain.careFacility.repository.CareFacilityRepository; import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.user.entity.User; @@ -42,6 +45,8 @@ public class ChatbotService { private final UserRepository userRepository; private final PolicyRepository policyRepository; private final CareFacilityRepository careFacilityRepository; + private final CareKnowledgeRetriever knowledgeRetriever; + private final ChatCompletionClient chatCompletionClient; // 의도 분석을 위한 키워드 패턴 private static final Map> INTENT_PATTERNS = new HashMap<>(); @@ -121,8 +126,8 @@ public ChatbotMessageResponse processMessage(ChatbotMessageRequest request) { ChatMessage.IntentType intentType = analyzeIntent(request.getMessage()); double confidence = calculateConfidence(request.getMessage(), intentType); - // 응답 생성 - String response = generateResponse(request.getMessage(), intentType, user); + // 응답 생성 (LLM 우선, 실패 시 규칙 기반 폴백) + String response = generateReply(request.getMessage(), intentType, user); // 메시지 저장 ChatMessage chatMessage = saveChatMessage(user, session, request.getMessage(), response, intentType, confidence); @@ -447,7 +452,35 @@ private double calculateConfidence(String message, ChatMessage.IntentType intent } - // 응답 생성 + /** + * 응답 생성. + * + *

DB 에서 관련 정책·시설을 검색해 근거로 넘기고 LLM 이 답하게 한다(RAG). + * API 키가 없거나 호출이 실패하면 아래 규칙 기반 응답으로 폴백하므로, + * LLM 을 붙이지 않은 환경에서도 챗봇은 그대로 동작한다. + */ + private String generateReply(String message, ChatMessage.IntentType intentType, User user) { + // 인사·감사·작별처럼 검색이 필요 없는 의도는 정형 응답이 더 빠르고 안정적이다. + if (intentType == ChatMessage.IntentType.GREETING + || intentType == ChatMessage.IntentType.THANKS + || intentType == ChatMessage.IntentType.GOODBYE) { + return generateResponse(message, intentType, user); + } + + if (chatCompletionClient.isAvailable()) { + RetrievedContext context = knowledgeRetriever.retrieve(message); + Optional llmReply = chatCompletionClient.generateReply(message, context); + if (llmReply.isPresent()) { + return llmReply.get(); + } + log.warn("LLM 응답 생성 실패 - 규칙 기반 응답으로 폴백합니다."); + } + + return generateResponse(message, intentType, user); + } + + + // 응답 생성 (규칙 기반 폴백) private String generateResponse(String message, ChatMessage.IntentType intentType, User user) { switch (intentType) { diff --git a/src/main/java/com/carecode/domain/community/controller/ModerationController.java b/src/main/java/com/carecode/domain/community/controller/ModerationController.java new file mode 100644 index 00000000..cf679de3 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/controller/ModerationController.java @@ -0,0 +1,58 @@ +package com.carecode.domain.community.controller; + +import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.domain.community.dto.request.ReportCreateRequest; +import com.carecode.domain.community.dto.response.ReportResponse; +import com.carecode.domain.community.service.ModerationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 커뮤니티 신고·차단 API (사용자용). + */ +@RestController +@RequestMapping("/community") +@RequiredArgsConstructor +@Tag(name = "커뮤니티 모더레이션", description = "게시글·댓글 신고 및 사용자 차단 API") +public class ModerationController { + + private final ModerationService moderationService; + + @PostMapping("/reports") + @LogExecutionTime + @Operation(summary = "게시글·댓글 신고", + description = "신고가 누적되면 관리자 확인 전까지 자동으로 숨김 처리됩니다.") + public ResponseEntity report(@Valid @RequestBody ReportCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(moderationService.report(request)); + } + + @PostMapping("/blocks/{userId}") + @LogExecutionTime + @Operation(summary = "사용자 차단", description = "차단한 사용자의 글과 댓글이 목록에서 보이지 않습니다.") + public ResponseEntity blockUser(@PathVariable Long userId) { + moderationService.blockUser(userId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/blocks/{userId}") + @LogExecutionTime + @Operation(summary = "사용자 차단 해제") + public ResponseEntity unblockUser(@PathVariable Long userId) { + moderationService.unblockUser(userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/blocks") + @LogExecutionTime + @Operation(summary = "내가 차단한 사용자 목록") + public ResponseEntity> getBlockedUsers() { + return ResponseEntity.ok(moderationService.getBlockedUserIds()); + } +} diff --git a/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java b/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java new file mode 100644 index 00000000..610ec8bc --- /dev/null +++ b/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java @@ -0,0 +1,29 @@ +package com.carecode.domain.community.dto.request; + +import com.carecode.domain.community.entity.Report; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 게시글·댓글 신고 요청. + */ +@Getter +@Setter +@NoArgsConstructor +public class ReportCreateRequest { + + @NotNull(message = "신고 대상 유형은 필수입니다") + private Report.TargetType targetType; + + @NotNull(message = "신고 대상 ID는 필수입니다") + private Long targetId; + + @NotNull(message = "신고 사유는 필수입니다") + private Report.ReportReason reason; + + @Size(max = 1000, message = "상세 내용은 1000자를 넘을 수 없습니다") + private String detail; +} diff --git a/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java b/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java new file mode 100644 index 00000000..88cfda3c --- /dev/null +++ b/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java @@ -0,0 +1,43 @@ +package com.carecode.domain.community.dto.response; + +import com.carecode.domain.community.entity.Report; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 신고 응답. + * + *

신고자 신원은 관리자에게도 최소한만 노출한다. + */ +@Getter +@Builder +public class ReportResponse { + + private final Long id; + private final String targetType; + private final Long targetId; + private final String reason; + private final String reasonDisplay; + private final String detail; + private final String status; + private final String moderatorNote; + private final LocalDateTime createdAt; + private final LocalDateTime resolvedAt; + + public static ReportResponse from(Report report) { + return ReportResponse.builder() + .id(report.getId()) + .targetType(report.getTargetType().name()) + .targetId(report.getTargetId()) + .reason(report.getReason().name()) + .reasonDisplay(report.getReason().getDisplayName()) + .detail(report.getDetail()) + .status(report.getStatus().name()) + .moderatorNote(report.getModeratorNote()) + .createdAt(report.getCreatedAt()) + .resolvedAt(report.getResolvedAt()) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/community/entity/Report.java b/src/main/java/com/carecode/domain/community/entity/Report.java new file mode 100644 index 00000000..224bc410 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/entity/Report.java @@ -0,0 +1,137 @@ +package com.carecode.domain.community.entity; + +import com.carecode.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 게시글·댓글 신고. + * + *

같은 사용자가 같은 대상을 중복 신고하지 못하도록 유니크 제약을 둔다. + */ +@Entity +@Table( + name = "TBL_REPORT", + uniqueConstraints = @UniqueConstraint( + name = "uk_report_reporter_target", + columnNames = {"reporter_id", "target_type", "target_id"} + ), + indexes = { + @Index(name = "idx_report_status", columnList = "status"), + @Index(name = "idx_report_target", columnList = "target_type,target_id") + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private TargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 30) + private ReportReason reason; + + @Column(name = "detail", length = 1000) + private String detail; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private ReportStatus status = ReportStatus.PENDING; + + /** 관리자 처리 메모. */ + @Column(name = "moderator_note", length = 1000) + private String moderatorNote; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + if (status == null) { + status = ReportStatus.PENDING; + } + } + + public void resolve(ReportStatus status, String note) { + this.status = status; + this.moderatorNote = note; + this.resolvedAt = LocalDateTime.now(); + } + + public enum TargetType { + POST("게시글"), + COMMENT("댓글"); + + private final String displayName; + + TargetType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } + + public enum ReportReason { + SPAM("스팸/광고"), + ABUSE("욕설/비방"), + SEXUAL("음란물"), + PRIVACY("개인정보 노출"), + FALSE_INFO("허위 정보"), + OTHER("기타"); + + private final String displayName; + + ReportReason(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } + + public enum ReportStatus { + PENDING("접수"), + ACCEPTED("조치 완료"), + REJECTED("반려"); + + private final String displayName; + + ReportStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } +} diff --git a/src/main/java/com/carecode/domain/community/entity/UserBlock.java b/src/main/java/com/carecode/domain/community/entity/UserBlock.java new file mode 100644 index 00000000..afd2eb19 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/entity/UserBlock.java @@ -0,0 +1,53 @@ +package com.carecode.domain.community.entity; + +import com.carecode.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자 차단. + * + *

차단하면 상대의 게시글·댓글이 목록에서 보이지 않는다. + */ +@Entity +@Table( + name = "TBL_USER_BLOCK", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_block", + columnNames = {"blocker_id", "blocked_id"} + ), + indexes = @Index(name = "idx_user_block_blocker", columnList = "blocker_id") +) +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserBlock { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 차단한 사용자. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocker_id", nullable = false) + private User blocker; + + /** 차단당한 사용자. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocked_id", nullable = false) + private User blocked; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/carecode/domain/community/repository/PostRepository.java b/src/main/java/com/carecode/domain/community/repository/PostRepository.java index 59aa0e83..fa1f1ac6 100644 --- a/src/main/java/com/carecode/domain/community/repository/PostRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/PostRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -41,4 +42,13 @@ public interface PostRepository extends JpaRepository { @Query("SELECT p FROM Post p JOIN p.tags t WHERE t = :tag AND p.isActive = true") List findByTagsContaining(@Param("tag") Tag tag); + + long countByAuthorId(Long authorId); + + /** + * 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Post p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :postId") + int incrementViewCount(@Param("postId") Long postId); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/community/repository/ReportRepository.java b/src/main/java/com/carecode/domain/community/repository/ReportRepository.java new file mode 100644 index 00000000..9b0b5d69 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/repository/ReportRepository.java @@ -0,0 +1,24 @@ +package com.carecode.domain.community.repository; + +import com.carecode.domain.community.entity.Report; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId(Long reporterId, + Report.TargetType targetType, + Long targetId); + + Page findByStatusOrderByCreatedAtAsc(Report.ReportStatus status, Pageable pageable); + + /** 대상별 누적 신고 수. 자동 숨김 임계치 판단에 사용한다. */ + @Query("SELECT COUNT(r) FROM Report r " + + "WHERE r.targetType = :targetType AND r.targetId = :targetId " + + "AND r.status <> com.carecode.domain.community.entity.Report.ReportStatus.REJECTED") + long countActiveReports(@Param("targetType") Report.TargetType targetType, + @Param("targetId") Long targetId); +} diff --git a/src/main/java/com/carecode/domain/community/repository/UserBlockRepository.java b/src/main/java/com/carecode/domain/community/repository/UserBlockRepository.java new file mode 100644 index 00000000..74313b35 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/repository/UserBlockRepository.java @@ -0,0 +1,22 @@ +package com.carecode.domain.community.repository; + +import com.carecode.domain.community.entity.UserBlock; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface UserBlockRepository extends JpaRepository { + + boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + Optional findByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + List findByBlockerIdOrderByCreatedAtDesc(Long blockerId); + + /** 내가 차단한 사용자 ID 목록. 목록 조회 시 필터링에 사용한다. */ + @Query("SELECT ub.blocked.id FROM UserBlock ub WHERE ub.blocker.id = :blockerId") + List findBlockedUserIds(@Param("blockerId") Long blockerId); +} diff --git a/src/main/java/com/carecode/domain/community/service/CommunityService.java b/src/main/java/com/carecode/domain/community/service/CommunityService.java index 825d4a6a..0e5c703d 100644 --- a/src/main/java/com/carecode/domain/community/service/CommunityService.java +++ b/src/main/java/com/carecode/domain/community/service/CommunityService.java @@ -1,6 +1,8 @@ package com.carecode.domain.community.service; import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.CommentAccessDeniedException; +import com.carecode.core.exception.PostAccessDeniedException; import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.domain.community.dto.request.CommunityCreatePostRequest; import com.carecode.domain.community.dto.request.CommunityUpdatePostRequest; @@ -24,6 +26,7 @@ import com.carecode.domain.community.entity.PostLike; import com.carecode.domain.community.entity.Bookmark; import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; import com.carecode.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -94,21 +97,17 @@ public CommunityPageResponse getAllPosts(int page, int si public CommunityPostDetailResponse getPostById(Long postId) { log.info("게시글 상세 조회 - 게시글 ID: {}", postId); - try { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); - - // 조회수 증가 - post.setViewCount(post.getViewCount() + 1); - postRepository.save(post); - - return communityMapper.toPostDetailResponse(post); - } catch (ResourceNotFoundException e) { - throw e; - } catch (Exception e) { - log.error("게시글 상세 조회 중 오류 발생: {}", e.getMessage()); - throw new CareServiceException("게시글을 조회하는 중 오류가 발생했습니다."); + + // 조회수는 DB 에서 원자적으로 증가시킨다 (동시 조회 시 증가분 유실 방지). + int updated = postRepository.incrementViewCount(postId); + if (updated == 0) { + throw new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId); } + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); + + return communityMapper.toPostDetailResponse(post); } @@ -145,40 +144,30 @@ public CommunityPostResponse createPost(CommunityCreatePostRequest request) { public CommunityPostResponse updatePost(Long postId, CommunityUpdatePostRequest request) { log.info("게시글 수정 - 게시글 ID: {}", postId); - try { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); - - post.setTitle(request.getTitle()); - post.setContent(request.getContent()); - post.setCategory(mapCategory(request.getCategory())); - - Post updatedPost = postRepository.save(post); - return communityMapper.toPostResponse(updatedPost); - } catch (ResourceNotFoundException e) { - throw e; - } catch (Exception e) { - log.error("게시글 수정 중 오류 발생: {}", e.getMessage()); - throw new CareServiceException("게시글을 수정하는 중 오류가 발생했습니다."); - } + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); + + requirePostOwnership(post); + + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setCategory(mapCategory(request.getCategory())); + + Post updatedPost = postRepository.save(post); + return communityMapper.toPostResponse(updatedPost); } - + // 게시글 삭제 public void deletePost(Long postId) { log.info("게시글 삭제 - 게시글 ID: {}", postId); - try { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); - - postRepository.delete(post); - } catch (ResourceNotFoundException e) { - throw e; - } catch (Exception e) { - log.error("게시글 삭제 중 오류 발생: {}", e.getMessage()); - throw new CareServiceException("게시글을 삭제하는 중 오류가 발생했습니다."); - } + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); + + requirePostOwnership(post); + + postRepository.delete(post); } @@ -254,12 +243,14 @@ public CommunityCommentResponse updateComment(Long commentId, CommunityUpdateCom Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ResourceNotFoundException("댓글을 찾을 수 없습니다. ID: " + commentId)); + requireCommentOwnership(comment); + comment.setContent(request.getContent()); Comment updatedComment = commentRepository.save(comment); return communityMapper.toCommentResponse(updatedComment); } - + // 댓글 삭제 @@ -267,6 +258,8 @@ public void deleteComment(Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ResourceNotFoundException("댓글을 찾을 수 없습니다. ID: " + commentId)); + requireCommentOwnership(comment); + Long postId = comment.getPost().getId(); commentRepository.delete(comment); @@ -337,6 +330,40 @@ private User getCurrentUser() { } + // 게시글 소유권 검증 (작성자 본인 또는 관리자만 허용) + + private void requirePostOwnership(Post post) { + User currentUser = getCurrentUser(); + if (isOwner(post.getAuthor(), currentUser) || isAdmin(currentUser)) { + return; + } + log.warn("게시글 접근 거부 - 게시글ID={}, 요청자ID={}", post.getId(), currentUser.getId()); + throw new PostAccessDeniedException("본인이 작성한 게시글만 수정/삭제할 수 있습니다."); + } + + + // 댓글 소유권 검증 (작성자 본인 또는 관리자만 허용) + + private void requireCommentOwnership(Comment comment) { + User currentUser = getCurrentUser(); + if (isOwner(comment.getAuthor(), currentUser) || isAdmin(currentUser)) { + return; + } + log.warn("댓글 접근 거부 - 댓글ID={}, 요청자ID={}", comment.getId(), currentUser.getId()); + throw new CommentAccessDeniedException("본인이 작성한 댓글만 수정/삭제할 수 있습니다."); + } + + private boolean isOwner(User author, User currentUser) { + return author != null + && author.getId() != null + && author.getId().equals(currentUser.getId()); + } + + private boolean isAdmin(User user) { + return UserRole.ADMIN == user.getRole(); + } + + // 카테고리 매핑 메서드 private PostCategory mapCategory(String category) { diff --git a/src/main/java/com/carecode/domain/community/service/ModerationService.java b/src/main/java/com/carecode/domain/community/service/ModerationService.java new file mode 100644 index 00000000..4a294c63 --- /dev/null +++ b/src/main/java/com/carecode/domain/community/service/ModerationService.java @@ -0,0 +1,171 @@ +package com.carecode.domain.community.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.community.dto.request.ReportCreateRequest; +import com.carecode.domain.community.dto.response.ReportResponse; +import com.carecode.domain.community.entity.Comment; +import com.carecode.domain.community.entity.Post; +import com.carecode.domain.community.entity.PostStatus; +import com.carecode.domain.community.entity.Report; +import com.carecode.domain.community.entity.UserBlock; +import com.carecode.domain.community.repository.CommentRepository; +import com.carecode.domain.community.repository.PostRepository; +import com.carecode.domain.community.repository.ReportRepository; +import com.carecode.domain.community.repository.UserBlockRepository; +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.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 커뮤니티 모더레이션: 신고 접수·처리, 사용자 차단. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ModerationService { + + private final ReportRepository reportRepository; + private final UserBlockRepository userBlockRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final CurrentUserFacade currentUserFacade; + + /** 이 횟수 이상 신고되면 관리자 확인 전까지 자동으로 숨긴다. */ + @Value("${app.community.auto-hide-report-threshold:5}") + private long autoHideThreshold; + + // ==================== 신고 ==================== + + @Transactional + public ReportResponse report(ReportCreateRequest request) { + User reporter = currentUserFacade.requireCurrentUser(); + + User targetAuthor = resolveTargetAuthor(request.getTargetType(), request.getTargetId()); + if (targetAuthor != null && targetAuthor.getId().equals(reporter.getId())) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "본인의 글은 신고할 수 없습니다."); + } + + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + reporter.getId(), request.getTargetType(), request.getTargetId())) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "이미 신고한 대상입니다."); + } + + Report report = Report.builder() + .reporter(reporter) + .targetType(request.getTargetType()) + .targetId(request.getTargetId()) + .reason(request.getReason()) + .detail(request.getDetail()) + .status(Report.ReportStatus.PENDING) + .build(); + + Report saved = reportRepository.save(report); + + applyAutoHideIfNeeded(request.getTargetType(), request.getTargetId()); + + return ReportResponse.from(saved); + } + + public Page getPendingReports(Pageable pageable) { + return reportRepository.findByStatusOrderByCreatedAtAsc(Report.ReportStatus.PENDING, pageable) + .map(ReportResponse::from); + } + + /** 관리자 처리: 수용(대상 숨김) 또는 반려(숨김 해제). */ + @Transactional + public ReportResponse resolve(Long reportId, Report.ReportStatus status, String note) { + if (status != Report.ReportStatus.ACCEPTED && status != Report.ReportStatus.REJECTED) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "처리 상태는 ACCEPTED 또는 REJECTED 여야 합니다."); + } + + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new ResourceNotFoundException("신고를 찾을 수 없습니다: " + reportId)); + + report.resolve(status, note); + + if (status == Report.ReportStatus.ACCEPTED) { + hideTarget(report.getTargetType(), report.getTargetId()); + } + return ReportResponse.from(reportRepository.save(report)); + } + + // ==================== 차단 ==================== + + @Transactional + public void blockUser(Long targetUserId) { + User blocker = currentUserFacade.requireCurrentUser(); + if (blocker.getId().equals(targetUserId)) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "자기 자신은 차단할 수 없습니다."); + } + if (userBlockRepository.existsByBlockerIdAndBlockedId(blocker.getId(), targetUserId)) { + return; + } + + User blocked = userRepository.findById(targetUserId) + .orElseThrow(() -> new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + targetUserId)); + + userBlockRepository.save(UserBlock.builder().blocker(blocker).blocked(blocked).build()); + } + + @Transactional + public void unblockUser(Long targetUserId) { + User blocker = currentUserFacade.requireCurrentUser(); + userBlockRepository.findByBlockerIdAndBlockedId(blocker.getId(), targetUserId) + .ifPresent(userBlockRepository::delete); + } + + /** 현재 사용자가 차단한 사용자 ID 목록. 목록 API 필터링에 사용한다. */ + public List getBlockedUserIds() { + User blocker = currentUserFacade.requireCurrentUser(); + return userBlockRepository.findBlockedUserIds(blocker.getId()); + } + + // ==================== 내부 ==================== + + private void applyAutoHideIfNeeded(Report.TargetType targetType, Long targetId) { + long reportCount = reportRepository.countActiveReports(targetType, targetId); + if (reportCount >= autoHideThreshold) { + log.warn("신고 누적으로 자동 숨김 처리 - type={}, id={}, 신고수={}", targetType, targetId, reportCount); + hideTarget(targetType, targetId); + } + } + + private void hideTarget(Report.TargetType targetType, Long targetId) { + if (targetType == Report.TargetType.POST) { + postRepository.findById(targetId).ifPresent(post -> { + post.setIsActive(false); + post.setStatus(PostStatus.HIDDEN); + postRepository.save(post); + }); + } else { + commentRepository.findById(targetId).ifPresent(comment -> { + comment.updateStatus(Comment.CommentStatus.HIDDEN); + commentRepository.save(comment); + }); + } + } + + private User resolveTargetAuthor(Report.TargetType targetType, Long targetId) { + if (targetType == Report.TargetType.POST) { + Post post = postRepository.findById(targetId) + .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다: " + targetId)); + return post.getAuthor(); + } + Comment comment = commentRepository.findById(targetId) + .orElseThrow(() -> new ResourceNotFoundException("댓글을 찾을 수 없습니다: " + targetId)); + return comment.getAuthor(); + } +} 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 30668200..f7316d3d 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -26,6 +26,7 @@ import com.carecode.domain.health.mapper.HospitalReviewMapper; import org.springframework.stereotype.Service; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import java.time.LocalDate; import java.util.List; @@ -149,8 +150,10 @@ public Map checkSystemHealth() { // 병원 관련 작업은 Facade에서 직접 처리하므로 트랜잭션 필요 // 하지만 Service 계층으로 이동하는 것이 더 나음 (향후 개선) - public List getAllHospitals() { - return hospitalRepository.findAll().stream() + public List getAllHospitals(int page, int size) { + // 테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. + return hospitalRepository.findAll(PageRequest.of(page, size, Sort.by("name"))) + .getContent().stream() .map(hospitalMapper::toResponse) .toList(); } diff --git a/src/main/java/com/carecode/domain/health/controller/ChildController.java b/src/main/java/com/carecode/domain/health/controller/ChildController.java new file mode 100644 index 00000000..ba4bdc19 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/controller/ChildController.java @@ -0,0 +1,132 @@ +package com.carecode.domain.health.controller; + +import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.domain.health.dto.request.ChildCreateRequest; +import com.carecode.domain.health.dto.response.ChildInfoResponse; +import com.carecode.domain.health.dto.response.GrowthPointResponse; +import com.carecode.domain.health.dto.response.VaccinationScheduleResponse; +import com.carecode.domain.health.growth.GrowthMetric; +import com.carecode.domain.health.service.ChildService; +import com.carecode.domain.health.service.GrowthChartService; +import com.carecode.domain.health.service.VaccinationScheduleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +/** + * 아이 정보 및 예방접종 일정 API. + */ +@Slf4j +@RestController +@RequestMapping("/children") +@RequiredArgsConstructor +@Tag(name = "아이 관리", description = "아이 등록·조회 및 예방접종 일정 API") +public class ChildController { + + private final ChildService childService; + private final VaccinationScheduleService vaccinationScheduleService; + private final GrowthChartService growthChartService; + + @PostMapping + @LogExecutionTime + @Operation(summary = "아이 등록", + description = "아이를 등록하고 생년월일 기준 표준 예방접종 일정을 자동 생성합니다.") + public ResponseEntity createChild(@Valid @RequestBody ChildCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(childService.createChild(request)); + } + + @GetMapping + @LogExecutionTime + @Operation(summary = "내 아이 목록 조회") + public ResponseEntity> getMyChildren() { + return ResponseEntity.ok(childService.getMyChildren()); + } + + @GetMapping("/{childId}") + @LogExecutionTime + @Operation(summary = "아이 상세 조회") + public ResponseEntity getChild(@PathVariable Long childId) { + return ResponseEntity.ok(childService.getChild(childId)); + } + + @PutMapping("/{childId}") + @LogExecutionTime + @Operation(summary = "아이 정보 수정") + public ResponseEntity updateChild(@PathVariable Long childId, + @Valid @RequestBody ChildCreateRequest request) { + return ResponseEntity.ok(childService.updateChild(childId, request)); + } + + @DeleteMapping("/{childId}") + @LogExecutionTime + @Operation(summary = "아이 삭제") + public ResponseEntity deleteChild(@PathVariable Long childId) { + childService.deleteChild(childId); + return ResponseEntity.noContent().build(); + } + + // ==================== 예방접종 일정 ==================== + + @GetMapping("/{childId}/vaccinations") + @LogExecutionTime + @Operation(summary = "예방접종 일정 조회", description = "표준 일정에 따른 접종 예정일과 완료 여부를 반환합니다.") + public ResponseEntity> getVaccinationSchedule(@PathVariable Long childId) { + // 소유권 검증을 위해 아이 조회를 먼저 태운다. + childService.getChild(childId); + return ResponseEntity.ok(vaccinationScheduleService.getSchedule(childId)); + } + + @GetMapping("/{childId}/vaccinations/overdue") + @LogExecutionTime + @Operation(summary = "미접종(기한 경과) 목록 조회") + public ResponseEntity> getOverdueVaccinations(@PathVariable Long childId) { + childService.getChild(childId); + return ResponseEntity.ok(vaccinationScheduleService.getOverdue(childId)); + } + + @PatchMapping("/{childId}/vaccinations/{scheduleId}/complete") + @LogExecutionTime + @Operation(summary = "접종 완료 처리") + public ResponseEntity completeVaccination( + @PathVariable Long childId, + @PathVariable Long scheduleId, + @Parameter(description = "실제 접종일 (미지정 시 오늘)") + @RequestParam(required = false) LocalDate completedDate) { + childService.getChild(childId); + return ResponseEntity.ok(vaccinationScheduleService.markCompleted(scheduleId, completedDate)); + } + + // ==================== 성장 곡선 ==================== + + @GetMapping("/{childId}/growth") + @LogExecutionTime + @Operation(summary = "성장 곡선 조회", + description = "기록된 키/몸무게를 WHO 성장 표준과 비교한 백분위와 함께 반환합니다. " + + "백분위는 참고 지표이며 진단은 의료진 판단을 따릅니다.") + public ResponseEntity> getGrowthChart( + @PathVariable Long childId, + @Parameter(description = "지표 (WEIGHT 또는 HEIGHT)") + @RequestParam(defaultValue = "WEIGHT") GrowthMetric metric) { + return ResponseEntity.ok(growthChartService.getGrowthChart(childId, metric)); + } + + @GetMapping("/{childId}/growth/latest") + @LogExecutionTime + @Operation(summary = "최근 측정 백분위 조회") + public ResponseEntity getLatestGrowth( + @PathVariable Long childId, + @RequestParam(defaultValue = "WEIGHT") GrowthMetric metric) { + return growthChartService.getLatestPercentile(childId, metric) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.noContent().build()); + } +} 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 0cb96bf7..81d7f787 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -2,6 +2,7 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.controller.BaseController; +import com.carecode.core.util.PageRequestUtil; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest; import com.carecode.domain.health.dto.request.HealthRecordAttachmentRequest; @@ -203,8 +204,11 @@ public ResponseEntity> getIntegratedRecommendation @GetMapping("/hospitals") @LogExecutionTime @Operation(summary = "모든 병원 조회", description = "등록된 모든 병원 정보를 조회합니다.") - public ResponseEntity> getAllHospitals() { - List hospitals = healthFacade.getAllHospitals(); + public ResponseEntity> getAllHospitals( + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(required = false) Integer page, + @Parameter(description = "페이지 크기 (최대 200)") @RequestParam(required = false) Integer size) { + List hospitals = healthFacade.getAllHospitals( + PageRequestUtil.normalizePage(page), PageRequestUtil.normalizeSize(size)); return ResponseEntity.ok(hospitals); } diff --git a/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java b/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java new file mode 100644 index 00000000..9beca249 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java @@ -0,0 +1,43 @@ +package com.carecode.domain.health.controller; + +import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.domain.health.dto.response.AttachmentResponse; +import com.carecode.domain.health.service.HealthRecordAttachmentService; +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 org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 건강기록 첨부파일 업로드 API. + * + *

기존 {@code POST /health/records/{id}/attachments} 는 이미 업로드된 파일의 + * URL·메타데이터를 JSON 으로 받는 등록 API 다. 실제 바이너리를 올릴 경로가 없어 + * 첨부 기능을 쓸 수 없었으므로, 여기서 multipart 업로드만 추가한다. + * 목록 조회·삭제는 기존 엔드포인트를 그대로 사용한다. + */ +@RestController +@RequestMapping("/health/records/{recordId}/attachments") +@RequiredArgsConstructor +@Tag(name = "건강기록 첨부 업로드", description = "예방접종 수첩, 진료 기록 사진 등 파일 업로드 API") +public class HealthRecordAttachmentController { + + private final HealthRecordAttachmentService attachmentService; + + @PostMapping(path = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @LogExecutionTime + @Operation(summary = "첨부파일 업로드", + description = "이미지 또는 PDF 를 업로드하고 건강기록에 연결합니다. (최대 10MB)") + public ResponseEntity upload( + @PathVariable Long recordId, + @Parameter(description = "업로드할 파일", required = true) @RequestPart("file") MultipartFile file, + @Parameter(description = "설명") @RequestParam(required = false) String description) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(attachmentService.upload(recordId, file, description)); + } +} diff --git a/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java b/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java new file mode 100644 index 00000000..bfa37945 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java @@ -0,0 +1,34 @@ +package com.carecode.domain.health.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 아이 등록/수정 요청. + */ +@Getter +@Setter +@NoArgsConstructor +public class ChildCreateRequest { + + @NotBlank(message = "아이 이름은 필수입니다") + @Size(max = 100, message = "이름은 100자를 넘을 수 없습니다") + private String name; + + @NotNull(message = "생년월일은 필수입니다") + @PastOrPresent(message = "생년월일은 오늘 이전이어야 합니다") + private LocalDate birthDate; + + @Size(max = 10) + private String gender; + + @Size(max = 500) + private String specialNeeds; +} diff --git a/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java b/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java new file mode 100644 index 00000000..8c881d6b --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java @@ -0,0 +1,40 @@ +package com.carecode.domain.health.dto.response; + +import com.carecode.domain.health.entity.HealthRecordAttachment; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 건강기록 첨부파일 응답. + */ +@Getter +@Builder +public class AttachmentResponse { + + private final Long id; + private final Long healthRecordId; + private final String fileUrl; + private final String fileName; + private final String fileType; + private final Long fileSize; + private final String description; + private final Integer displayOrder; + private final LocalDateTime createdAt; + + public static AttachmentResponse from(HealthRecordAttachment attachment) { + return AttachmentResponse.builder() + .id(attachment.getId()) + .healthRecordId(attachment.getHealthRecord() != null + ? attachment.getHealthRecord().getId() : null) + .fileUrl(attachment.getFileUrl()) + .fileName(attachment.getFileName()) + .fileType(attachment.getFileType()) + .fileSize(attachment.getFileSize()) + .description(attachment.getDescription()) + .displayOrder(attachment.getDisplayOrder()) + .createdAt(attachment.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java b/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java new file mode 100644 index 00000000..5c3d3028 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java @@ -0,0 +1,53 @@ +package com.carecode.domain.health.dto.response; + +import com.carecode.domain.health.growth.GrowthMetric; +import com.carecode.domain.health.growth.GrowthPercentileResult; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +/** + * 성장 곡선의 한 지점. + * + *

백분위는 성별/생년월일이 없거나 WHO 표준 적용 범위(0~60개월)를 벗어나면 null 이다. + */ +@Getter +@Builder +public class GrowthPointResponse { + + private final LocalDate recordDate; + private final int ageMonths; + private final double value; + private final String metric; + private final String unit; + + private final Double percentile; + private final Double zScore; + private final Double medianValue; + private final String interpretation; + private final Boolean needsAttention; + + public static GrowthPointResponse of(LocalDate recordDate, + int ageMonths, + double value, + GrowthMetric metric, + GrowthPercentileResult percentileResult) { + GrowthPointResponseBuilder builder = GrowthPointResponse.builder() + .recordDate(recordDate) + .ageMonths(ageMonths) + .value(value) + .metric(metric.name()) + .unit(metric.getUnit()); + + if (percentileResult != null) { + builder.percentile(percentileResult.percentile()) + .zScore(percentileResult.zScore()) + .medianValue(percentileResult.medianValue()) + .interpretation(percentileResult.interpretation()) + .needsAttention(percentileResult.needsAttention()); + } + + return builder.build(); + } +} diff --git a/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java b/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java new file mode 100644 index 00000000..bc19edd7 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java @@ -0,0 +1,41 @@ +package com.carecode.domain.health.dto.response; + +import com.carecode.domain.health.entity.VaccinationSchedule; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +/** + * 예방접종 일정 응답. + */ +@Getter +@Builder +public class VaccinationScheduleResponse { + + private final Long id; + private final Long childId; + private final String vaccineType; + private final String vaccineName; + private final Integer doseNumber; + private final Integer totalDoses; + private final LocalDate dueDate; + private final LocalDate completedDate; + private final String status; + private final boolean overdue; + + public static VaccinationScheduleResponse from(VaccinationSchedule schedule) { + return VaccinationScheduleResponse.builder() + .id(schedule.getId()) + .childId(schedule.getChild() != null ? schedule.getChild().getId() : null) + .vaccineType(schedule.getVaccineType().name()) + .vaccineName(schedule.getVaccineType().getDisplayName()) + .doseNumber(schedule.getDoseNumber()) + .totalDoses(schedule.getVaccineType().getTotalDoses()) + .dueDate(schedule.getDueDate()) + .completedDate(schedule.getCompletedDate()) + .status(schedule.getStatus().name()) + .overdue(schedule.isOverdue(LocalDate.now())) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/health/entity/Hospital.java b/src/main/java/com/carecode/domain/health/entity/Hospital.java index f378aa60..c6fdb7a4 100644 --- a/src/main/java/com/carecode/domain/health/entity/Hospital.java +++ b/src/main/java/com/carecode/domain/health/entity/Hospital.java @@ -20,6 +20,10 @@ public class Hospital { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** 심평원 암호화 요양기호(ykiho). */ + @Column(name = "external_code", unique = true, length = 100) + private String externalCode; + @Column(nullable = false) private String name; diff --git a/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java b/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java new file mode 100644 index 00000000..61a6aa69 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java @@ -0,0 +1,122 @@ +package com.carecode.domain.health.entity; + +import com.carecode.domain.user.entity.Child; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 아이별 예방접종 일정. + * + *

아이를 등록하면 {@link VaccineType} 표준 일정에 따라 접종 예정일이 자동으로 생성된다. + * 스케줄러가 예정일이 임박한 항목을 찾아 보호자에게 알림을 보낸다. + */ +@Entity +@Table( + name = "TBL_VACCINATION_SCHEDULE", + uniqueConstraints = @UniqueConstraint( + name = "uk_vaccination_child_vaccine_dose", + columnNames = {"child_id", "vaccine_type", "dose_number"} + ), + indexes = { + @Index(name = "idx_vaccination_child", columnList = "child_id"), + @Index(name = "idx_vaccination_due_date", columnList = "due_date"), + @Index(name = "idx_vaccination_status", columnList = "status") + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VaccinationSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "child_id", nullable = false) + private Child child; + + @Enumerated(EnumType.STRING) + @Column(name = "vaccine_type", nullable = false, length = 50) + private VaccineType vaccineType; + + /** 같은 백신의 몇 차 접종인지 (1부터). */ + @Column(name = "dose_number", nullable = false) + private Integer doseNumber; + + /** 표준 일정상 접종 예정일. */ + @Column(name = "due_date", nullable = false) + private LocalDate dueDate; + + /** 실제 접종일. 완료 처리 시 기록된다. */ + @Column(name = "completed_date") + private LocalDate completedDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private VaccinationStatus status = VaccinationStatus.SCHEDULED; + + /** 사전 알림을 이미 보냈는지. 중복 발송을 막는다. */ + @Column(name = "reminder_sent_at") + private LocalDateTime reminderSentAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (status == null) { + status = VaccinationStatus.SCHEDULED; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public void markCompleted(LocalDate completedDate) { + this.completedDate = completedDate; + this.status = VaccinationStatus.COMPLETED; + } + + public void markReminderSent() { + this.reminderSentAt = LocalDateTime.now(); + } + + /** 예정일이 지났는데 아직 접종하지 않은 상태인지. */ + public boolean isOverdue(LocalDate today) { + return status == VaccinationStatus.SCHEDULED && dueDate.isBefore(today); + } + + public enum VaccinationStatus { + SCHEDULED("예정"), + COMPLETED("완료"), + SKIPPED("건너뜀"); + + private final String displayName; + + VaccinationStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } +} diff --git a/src/main/java/com/carecode/domain/health/entity/VaccineType.java b/src/main/java/com/carecode/domain/health/entity/VaccineType.java new file mode 100644 index 00000000..b0ef960d --- /dev/null +++ b/src/main/java/com/carecode/domain/health/entity/VaccineType.java @@ -0,0 +1,67 @@ +package com.carecode.domain.health.entity; + +import java.util.Arrays; +import java.util.List; + +/** + * 국가예방접종(NIP) 표준 일정. + * + *

각 백신의 회차별 접종 시기를 생후 개월 수로 정의한다. + * 아이 등록 시 생년월일에 이 개월 수를 더해 접종 예정일을 계산한다. + * + *

주의: 접종 시기는 질병관리청 지침에 따라 바뀔 수 있다. + * 여기 값은 표준 일정이며, 실제 접종은 의료진 판단을 따른다. + */ +public enum VaccineType { + + BCG("BCG(결핵)", List.of(0)), + HEP_B("B형간염", List.of(0, 1, 6)), + DTAP("DTaP(디프테리아·파상풍·백일해)", List.of(2, 4, 6, 15, 48)), + IPV("폴리오", List.of(2, 4, 6, 48)), + HIB("Hib(b형헤모필루스인플루엔자)", List.of(2, 4, 6, 12)), + PCV("폐렴구균", List.of(2, 4, 6, 12)), + ROTAVIRUS("로타바이러스", List.of(2, 4, 6)), + MMR("MMR(홍역·유행성이하선염·풍진)", List.of(12, 48)), + VARICELLA("수두", List.of(12)), + HEP_A("A형간염", List.of(12, 18)), + JAPANESE_ENCEPHALITIS("일본뇌염", List.of(12, 13, 24, 72)), + INFLUENZA("인플루엔자", List.of(6)); + + private final String displayName; + + /** 회차별 접종 시기(생후 개월 수). 인덱스 0 이 1차. */ + private final List doseMonths; + + VaccineType(String displayName, List doseMonths) { + this.displayName = displayName; + this.doseMonths = doseMonths; + } + + public String getDisplayName() { + return displayName; + } + + public List getDoseMonths() { + return doseMonths; + } + + public int getTotalDoses() { + return doseMonths.size(); + } + + /** + * @param doseNumber 1부터 시작하는 회차 + * @return 해당 회차의 접종 시기(생후 개월 수) + */ + public int getMonthsForDose(int doseNumber) { + if (doseNumber < 1 || doseNumber > doseMonths.size()) { + throw new IllegalArgumentException( + name() + " 백신에 존재하지 않는 회차입니다: " + doseNumber); + } + return doseMonths.get(doseNumber - 1); + } + + public static List all() { + return Arrays.asList(values()); + } +} diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java b/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java new file mode 100644 index 00000000..1f349be3 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java @@ -0,0 +1,25 @@ +package com.carecode.domain.health.growth; + +/** + * 성장 지표 종류. + */ +public enum GrowthMetric { + WEIGHT("체중", "kg"), + HEIGHT("신장", "cm"); + + private final String displayName; + private final String unit; + + GrowthMetric(String displayName, String unit) { + this.displayName = displayName; + this.unit = unit; + } + + public String getDisplayName() { + return displayName; + } + + public String getUnit() { + return unit; + } +} diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java new file mode 100644 index 00000000..430b1a1a --- /dev/null +++ b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java @@ -0,0 +1,76 @@ +package com.carecode.domain.health.growth; + +import java.util.Optional; + +/** + * WHO LMS 방식 백분위 계산기. + * + *

Z-score = ((측정값 / M)^L - 1) / (L × S), L=0 이면 ln(측정값/M) / S. + * 계산한 Z-score 를 표준정규 누적분포에 통과시켜 백분위를 얻는다. + */ +public final class GrowthPercentileCalculator { + + private GrowthPercentileCalculator() { + } + + /** + * @return 계산 결과. 표준표 범위를 벗어나거나 입력이 유효하지 않으면 empty + */ + public static Optional calculate(GrowthMetric metric, + Sex sex, + int ageMonths, + double measuredValue) { + if (measuredValue <= 0) { + return Optional.empty(); + } + + return GrowthStandardTable.lookup(metric, sex, ageMonths).map(standard -> { + double zScore = toZScore(measuredValue, standard); + double percentile = normalCdf(zScore) * 100.0; + return new GrowthPercentileResult( + metric, + ageMonths, + measuredValue, + standard.m(), + round(zScore, 2), + round(percentile, 1)); + }); + } + + private static double toZScore(double value, GrowthStandard standard) { + double l = standard.l(); + double m = standard.m(); + double s = standard.s(); + + if (Math.abs(l) < 1e-9) { + return Math.log(value / m) / s; + } + return (Math.pow(value / m, l) - 1) / (l * s); + } + + /** + * 표준정규 누적분포함수. + * Abramowitz & Stegun 7.1.26 근사식을 사용한다 (오차 < 1.5e-7). + */ + static double normalCdf(double z) { + return 0.5 * (1.0 + erf(z / Math.sqrt(2.0))); + } + + private static double erf(double x) { + double sign = Math.signum(x); + double absX = Math.abs(x); + + double a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741; + double a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911; + + double t = 1.0 / (1.0 + p * absX); + double y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-absX * absX); + + return sign * y; + } + + private static double round(double value, int decimals) { + double factor = Math.pow(10, decimals); + return Math.round(value * factor) / factor; + } +} diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java new file mode 100644 index 00000000..dc93b0e5 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java @@ -0,0 +1,44 @@ +package com.carecode.domain.health.growth; + +/** + * 성장 백분위 계산 결과. + * + * @param metric 지표 종류 + * @param ageMonths 측정 시점의 생후 개월 수 + * @param measuredValue 측정값 + * @param medianValue 같은 연령·성별의 표준 중앙값(50 백분위) + * @param zScore 표준화 점수 + * @param percentile 백분위 (0~100) + */ +public record GrowthPercentileResult( + GrowthMetric metric, + int ageMonths, + double measuredValue, + double medianValue, + double zScore, + double percentile) { + + /** + * 임상적 주의가 필요한 범위인지. + * WHO 는 |Z| > 2 를 주의 구간으로 본다. + */ + public boolean needsAttention() { + return Math.abs(zScore) > 2.0; + } + + public String interpretation() { + if (zScore < -3) { + return "표준보다 매우 낮음 (전문의 상담 권장)"; + } + if (zScore < -2) { + return "표준보다 낮음 (경과 관찰 필요)"; + } + if (zScore > 3) { + return "표준보다 매우 높음 (전문의 상담 권장)"; + } + if (zScore > 2) { + return "표준보다 높음 (경과 관찰 필요)"; + } + return "정상 범위"; + } +} diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java b/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java new file mode 100644 index 00000000..ffc7ce0f --- /dev/null +++ b/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java @@ -0,0 +1,15 @@ +package com.carecode.domain.health.growth; + +/** + * WHO 아동 성장 표준의 LMS 파라미터. + * + *

WHO 는 연령·성별별로 L(왜도), M(중앙값), S(변동계수) 세 값을 제공하며, + * 이 값으로 개별 측정치의 Z-score 와 백분위를 계산한다. + * + * @param ageMonths 생후 개월 수 + * @param l Box-Cox 변환 지수 + * @param m 중앙값 + * @param s 변동계수 + */ +public record GrowthStandard(int ageMonths, double l, double m, double s) { +} diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java b/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java new file mode 100644 index 00000000..5e8083e6 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java @@ -0,0 +1,125 @@ +package com.carecode.domain.health.growth; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * WHO 아동 성장 표준(0~60개월) LMS 표. + * + *

출처: WHO Child Growth Standards (weight-for-age, length/height-for-age). + * 표는 6개월 간격 발췌본이며, 사이 값은 선형 보간한다. + * 정밀한 임상 판단이 필요하면 WHO 전체 표를 적재해 대체할 수 있다. + * + *

주의: 백분위는 참고 지표다. 진단은 의료진의 판단을 따른다. + */ +public final class GrowthStandardTable { + + /** 남아 체중(kg) for age. */ + private static final List BOY_WEIGHT = List.of( + new GrowthStandard(0, 0.3487, 3.3464, 0.14602), + new GrowthStandard(6, 0.1738, 7.9340, 0.12385), + new GrowthStandard(12, 0.0402, 9.6479, 0.12106), + new GrowthStandard(18, -0.0756, 10.9385, 0.12237), + new GrowthStandard(24, -0.1733, 12.1515, 0.12456), + new GrowthStandard(30, -0.2565, 13.3000, 0.12718), + new GrowthStandard(36, -0.3277, 14.3429, 0.12988), + new GrowthStandard(42, -0.3891, 15.3160, 0.13257), + new GrowthStandard(48, -0.4425, 16.3497, 0.13519), + new GrowthStandard(54, -0.4894, 17.3140, 0.13774), + new GrowthStandard(60, -0.5308, 18.3074, 0.14021)); + + /** 여아 체중(kg) for age. */ + private static final List GIRL_WEIGHT = List.of( + new GrowthStandard(0, 0.3809, 3.2322, 0.14171), + new GrowthStandard(6, 0.1002, 7.2970, 0.12619), + new GrowthStandard(12, -0.0756, 8.9481, 0.12839), + new GrowthStandard(18, -0.1972, 10.2315, 0.13089), + new GrowthStandard(24, -0.2890, 11.4775, 0.13341), + new GrowthStandard(30, -0.3624, 12.6489, 0.13587), + new GrowthStandard(36, -0.4232, 13.7626, 0.13827), + new GrowthStandard(42, -0.4750, 14.8442, 0.14061), + new GrowthStandard(48, -0.5199, 15.9036, 0.14290), + new GrowthStandard(54, -0.5594, 16.9481, 0.14515), + new GrowthStandard(60, -0.5946, 17.9873, 0.14738)); + + /** 남아 신장(cm) for age. */ + private static final List BOY_HEIGHT = List.of( + new GrowthStandard(0, 1.0, 49.8842, 0.03795), + new GrowthStandard(6, 1.0, 67.6236, 0.03165), + new GrowthStandard(12, 1.0, 75.7488, 0.03317), + new GrowthStandard(18, 1.0, 82.2587, 0.03468), + new GrowthStandard(24, 1.0, 87.8161, 0.03610), + new GrowthStandard(30, 1.0, 92.1131, 0.03765), + new GrowthStandard(36, 1.0, 96.0835, 0.03902), + new GrowthStandard(42, 1.0, 99.8003, 0.04026), + new GrowthStandard(48, 1.0, 103.3273, 0.04141), + new GrowthStandard(54, 1.0, 106.7050, 0.04250), + new GrowthStandard(60, 1.0, 110.0000, 0.04352)); + + /** 여아 신장(cm) for age. */ + private static final List GIRL_HEIGHT = List.of( + new GrowthStandard(0, 1.0, 49.1477, 0.03790), + new GrowthStandard(6, 1.0, 65.7311, 0.03395), + new GrowthStandard(12, 1.0, 74.0150, 0.03568), + new GrowthStandard(18, 1.0, 80.7079, 0.03737), + new GrowthStandard(24, 1.0, 86.4153, 0.03894), + new GrowthStandard(30, 1.0, 90.9915, 0.04039), + new GrowthStandard(36, 1.0, 95.0515, 0.04171), + new GrowthStandard(42, 1.0, 98.7680, 0.04291), + new GrowthStandard(48, 1.0, 102.2665, 0.04401), + new GrowthStandard(54, 1.0, 105.6003, 0.04503), + new GrowthStandard(60, 1.0, 108.7900, 0.04598)); + + private static final Map> TABLES = Map.of( + new Key(GrowthMetric.WEIGHT, Sex.MALE), BOY_WEIGHT, + new Key(GrowthMetric.WEIGHT, Sex.FEMALE), GIRL_WEIGHT, + new Key(GrowthMetric.HEIGHT, Sex.MALE), BOY_HEIGHT, + new Key(GrowthMetric.HEIGHT, Sex.FEMALE), GIRL_HEIGHT); + + private GrowthStandardTable() { + } + + /** + * 해당 연령의 LMS 값을 구한다. 표에 없는 개월 수는 인접 구간을 선형 보간한다. + * + * @return 표 범위를 벗어나면 {@link Optional#empty()} + */ + public static Optional lookup(GrowthMetric metric, Sex sex, int ageMonths) { + List table = TABLES.get(new Key(metric, sex)); + if (table == null || ageMonths < 0) { + return Optional.empty(); + } + + int maxAge = table.get(table.size() - 1).ageMonths(); + if (ageMonths > maxAge) { + // 60개월을 넘어가면 WHO 성장 표준의 적용 범위를 벗어난다. + return Optional.empty(); + } + + for (int i = 0; i < table.size(); i++) { + GrowthStandard current = table.get(i); + if (current.ageMonths() == ageMonths) { + return Optional.of(current); + } + if (current.ageMonths() > ageMonths) { + GrowthStandard previous = table.get(i - 1); + return Optional.of(interpolate(previous, current, ageMonths)); + } + } + return Optional.empty(); + } + + private static GrowthStandard interpolate(GrowthStandard low, GrowthStandard high, int ageMonths) { + double span = high.ageMonths() - low.ageMonths(); + double ratio = (ageMonths - low.ageMonths()) / span; + return new GrowthStandard( + ageMonths, + low.l() + (high.l() - low.l()) * ratio, + low.m() + (high.m() - low.m()) * ratio, + low.s() + (high.s() - low.s()) * ratio); + } + + private record Key(GrowthMetric metric, Sex sex) { + } +} diff --git a/src/main/java/com/carecode/domain/health/growth/Sex.java b/src/main/java/com/carecode/domain/health/growth/Sex.java new file mode 100644 index 00000000..6eb72c55 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/growth/Sex.java @@ -0,0 +1,26 @@ +package com.carecode.domain.health.growth; + +import java.util.Locale; +import java.util.Optional; + +/** + * 성장 표준 조회를 위한 성별. + * + *

아이 엔티티의 gender 는 자유 문자열이라 다양한 표기가 들어올 수 있어 관대하게 파싱한다. + */ +public enum Sex { + MALE, + FEMALE; + + public static Optional parse(String raw) { + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + String value = raw.trim().toLowerCase(Locale.ROOT); + return switch (value) { + case "m", "male", "남", "남자", "남아", "boy" -> Optional.of(MALE); + case "f", "female", "여", "여자", "여아", "girl" -> Optional.of(FEMALE); + default -> Optional.empty(); + }; + } +} diff --git a/src/main/java/com/carecode/domain/health/repository/HealthRecordAttachmentRepository.java b/src/main/java/com/carecode/domain/health/repository/HealthRecordAttachmentRepository.java index 48a40e9b..114da8b6 100644 --- a/src/main/java/com/carecode/domain/health/repository/HealthRecordAttachmentRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HealthRecordAttachmentRepository.java @@ -8,4 +8,8 @@ public interface HealthRecordAttachmentRepository extends JpaRepository { List findByHealthRecordAndIsActiveTrueOrderByDisplayOrderAscCreatedAtDesc(HealthRecord healthRecord); + + List findByHealthRecordIdOrderByDisplayOrderAsc(Long healthRecordId); + + long countByHealthRecordId(Long healthRecordId); } diff --git a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java index 53f0a20d..1c0638cb 100644 --- a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java @@ -6,10 +6,14 @@ import org.springframework.data.repository.query.Param; import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Optional; public interface HospitalRepository extends JpaRepository { List findByType(String type); + /** 공공데이터 동기화 시 중복 적재 방지용 조회. */ + Optional findByExternalCode(String externalCode); + @Query("SELECT h FROM Hospital h WHERE FUNCTION('ST_Distance_Sphere', point(h.longitude, h.latitude), point(:lng, :lat)) <= :radius") List findNearby(@Param("lat") double lat, @Param("lng") double lng, @Param("radius") double radius); diff --git a/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java b/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java new file mode 100644 index 00000000..e2c5d4b2 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java @@ -0,0 +1,38 @@ +package com.carecode.domain.health.repository; + +import com.carecode.domain.health.entity.VaccinationSchedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface VaccinationScheduleRepository extends JpaRepository { + + List findByChildIdOrderByDueDateAsc(Long childId); + + boolean existsByChildId(Long childId); + + /** + * 알림 대상 조회: 예정일이 구간 안에 있고 아직 알림을 보내지 않은 미완료 일정. + * + *

child, user 를 함께 로딩해 알림 발송 시 N+1 을 피한다. + */ + @Query("SELECT vs FROM VaccinationSchedule vs " + + "JOIN FETCH vs.child c JOIN FETCH c.user " + + "WHERE vs.status = com.carecode.domain.health.entity.VaccinationSchedule.VaccinationStatus.SCHEDULED " + + "AND vs.reminderSentAt IS NULL " + + "AND vs.dueDate BETWEEN :from AND :to") + List findPendingReminders(@Param("from") LocalDate from, + @Param("to") LocalDate to); + + @Query("SELECT vs FROM VaccinationSchedule vs " + + "WHERE vs.child.id = :childId " + + "AND vs.status = com.carecode.domain.health.entity.VaccinationSchedule.VaccinationStatus.SCHEDULED " + + "AND vs.dueDate < :today ORDER BY vs.dueDate ASC") + List findOverdue(@Param("childId") Long childId, + @Param("today") LocalDate today); +} diff --git a/src/main/java/com/carecode/domain/health/service/ChildService.java b/src/main/java/com/carecode/domain/health/service/ChildService.java new file mode 100644 index 00000000..f591e577 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/ChildService.java @@ -0,0 +1,109 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.ChildNotFoundException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.health.dto.request.ChildCreateRequest; +import com.carecode.domain.health.dto.response.ChildInfoResponse; +import com.carecode.domain.health.mapper.ChildMapper; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Period; +import java.util.List; + +/** + * 아이 정보 관리. + * + *

등록 시 표준 예방접종 일정을 함께 생성한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChildService { + + private final ChildRepository childRepository; + private final ChildMapper childMapper; + private final CurrentUserFacade currentUserFacade; + private final VaccinationScheduleService vaccinationScheduleService; + + @Transactional + public ChildInfoResponse createChild(ChildCreateRequest request) { + User parent = currentUserFacade.requireCurrentUser(); + + Child child = Child.builder() + .user(parent) + .name(request.getName()) + .birthDate(request.getBirthDate()) + .age(calculateAge(request.getBirthDate())) + .gender(request.getGender()) + .specialNeeds(request.getSpecialNeeds()) + .build(); + + Child saved = childRepository.save(child); + log.info("아이 등록 - childId={}, userId={}", saved.getId(), parent.getId()); + + // 생년월일 기준 표준 접종 일정 자동 생성 + vaccinationScheduleService.generateScheduleForChild(saved.getId()); + + return childMapper.toResponse(saved); + } + + public List getMyChildren() { + User parent = currentUserFacade.requireCurrentUser(); + return childRepository.findByUserIdOrderByCreatedAtDesc(parent.getId()).stream() + .map(childMapper::toResponse) + .toList(); + } + + public ChildInfoResponse getChild(Long childId) { + return childMapper.toResponse(requireOwnedChild(childId)); + } + + @Transactional + public ChildInfoResponse updateChild(Long childId, ChildCreateRequest request) { + Child child = requireOwnedChild(childId); + + child.setName(request.getName()); + child.setBirthDate(request.getBirthDate()); + child.setAge(calculateAge(request.getBirthDate())); + child.setGender(request.getGender()); + child.setSpecialNeeds(request.getSpecialNeeds()); + + return childMapper.toResponse(childRepository.save(child)); + } + + @Transactional + public void deleteChild(Long childId) { + childRepository.delete(requireOwnedChild(childId)); + } + + /** + * 아이 조회 + 소유권 검증. + * 남의 아이 정보에 접근하지 못하도록 보호자 본인 것만 반환한다. + */ + private Child requireOwnedChild(Long childId) { + User parent = currentUserFacade.requireCurrentUser(); + Child child = childRepository.findById(childId) + .orElseThrow(() -> new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId)); + + if (child.getUser() == null || !child.getUser().getId().equals(parent.getId())) { + // 존재 여부 자체를 숨기기 위해 404 로 응답한다. + throw new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId); + } + return child; + } + + private Integer calculateAge(LocalDate birthDate) { + if (birthDate == null) { + return null; + } + return Period.between(birthDate, LocalDate.now()).getYears(); + } +} diff --git a/src/main/java/com/carecode/domain/health/service/GrowthChartService.java b/src/main/java/com/carecode/domain/health/service/GrowthChartService.java new file mode 100644 index 00000000..a06a5483 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/GrowthChartService.java @@ -0,0 +1,99 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.ChildNotFoundException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.health.dto.response.GrowthPointResponse; +import com.carecode.domain.health.entity.HealthRecord; +import com.carecode.domain.health.growth.GrowthMetric; +import com.carecode.domain.health.growth.GrowthPercentileCalculator; +import com.carecode.domain.health.growth.GrowthPercentileResult; +import com.carecode.domain.health.growth.Sex; +import com.carecode.domain.health.repository.HealthRecordRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * 아이 성장 곡선. + * + *

기록된 키/몸무게를 WHO 성장 표준과 비교해 백분위를 함께 제공한다. + * 기존 차트 API 는 측정값만 나열해서 "또래와 비교해 어떤지" 를 알 수 없었다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GrowthChartService { + + private final ChildRepository childRepository; + private final HealthRecordRepository healthRecordRepository; + private final CurrentUserFacade currentUserFacade; + + public List getGrowthChart(Long childId, GrowthMetric metric) { + Child child = requireOwnedChild(childId); + + if (child.getBirthDate() == null) { + log.warn("생년월일이 없어 백분위를 계산할 수 없습니다. childId={}", childId); + return List.of(); + } + + Optional sex = Sex.parse(child.getGender()); + Function valueExtractor = metric == GrowthMetric.WEIGHT + ? HealthRecord::getWeight + : HealthRecord::getHeight; + + List points = new ArrayList<>(); + List records = healthRecordRepository.findByChildOrderByRecordDateDesc(child); + + for (HealthRecord record : records) { + Double value = valueExtractor.apply(record); + if (value == null || record.getRecordDate() == null) { + continue; + } + + int ageMonths = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), record.getRecordDate()); + GrowthPercentileResult percentile = sex + .flatMap(s -> GrowthPercentileCalculator.calculate(metric, s, ageMonths, value)) + .orElse(null); + + points.add(GrowthPointResponse.of(record.getRecordDate(), ageMonths, value, metric, percentile)); + } + + points.sort(Comparator.comparing(GrowthPointResponse::getRecordDate)); + return points; + } + + /** 가장 최근 측정치에 대한 백분위 요약. */ + public Optional getLatestPercentile(Long childId, GrowthMetric metric) { + List points = getGrowthChart(childId, metric); + return points.isEmpty() ? Optional.empty() : Optional.of(points.get(points.size() - 1)); + } + + private Child requireOwnedChild(Long childId) { + User parent = currentUserFacade.requireCurrentUser(); + Child child = childRepository.findById(childId) + .orElseThrow(() -> new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId)); + + if (child.getUser() == null || !child.getUser().getId().equals(parent.getId())) { + throw new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId); + } + return child; + } + + /** 오늘 기준 개월 수. */ + public static int ageInMonths(LocalDate birthDate) { + return (int) ChronoUnit.MONTHS.between(birthDate, LocalDate.now()); + } +} diff --git a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java new file mode 100644 index 00000000..8a228b6b --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java @@ -0,0 +1,95 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.HealthRecordNotFoundException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.core.storage.FileStorageService; +import com.carecode.core.storage.StoredFile; +import com.carecode.domain.health.dto.response.AttachmentResponse; +import com.carecode.domain.health.entity.HealthRecord; +import com.carecode.domain.health.entity.HealthRecordAttachment; +import com.carecode.domain.health.repository.HealthRecordAttachmentRepository; +import com.carecode.domain.health.repository.HealthRecordRepository; +import com.carecode.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 건강기록 첨부파일 관리. + * + *

첨부 엔티티와 테이블은 있었지만 업로드 경로가 없어 사용할 수 없던 기능을 연결한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HealthRecordAttachmentService { + + private static final String DIRECTORY = "health-records"; + + private final HealthRecordRepository healthRecordRepository; + private final HealthRecordAttachmentRepository attachmentRepository; + private final FileStorageService fileStorageService; + private final CurrentUserFacade currentUserFacade; + + @Transactional + public AttachmentResponse upload(Long recordId, MultipartFile file, String description) { + HealthRecord record = requireOwnedRecord(recordId); + + StoredFile stored = fileStorageService.store(file, DIRECTORY); + + HealthRecordAttachment attachment = HealthRecordAttachment.builder() + .healthRecord(record) + .fileUrl(stored.getUrl()) + .fileName(stored.getOriginalFilename()) + .fileType(stored.getContentType()) + .fileSize(stored.getSize()) + .description(description) + .displayOrder((int) attachmentRepository.countByHealthRecordId(recordId)) + .build(); + + return AttachmentResponse.from(attachmentRepository.save(attachment)); + } + + public List list(Long recordId) { + requireOwnedRecord(recordId); + return attachmentRepository.findByHealthRecordIdOrderByDisplayOrderAsc(recordId).stream() + .map(AttachmentResponse::from) + .toList(); + } + + @Transactional + public void delete(Long recordId, Long attachmentId) { + requireOwnedRecord(recordId); + + HealthRecordAttachment attachment = attachmentRepository.findById(attachmentId) + .orElseThrow(() -> new IllegalArgumentException("첨부파일을 찾을 수 없습니다: " + attachmentId)); + + if (attachment.getHealthRecord() == null + || !attachment.getHealthRecord().getId().equals(recordId)) { + throw new IllegalArgumentException("첨부파일을 찾을 수 없습니다: " + attachmentId); + } + + attachmentRepository.delete(attachment); + } + + /** + * 건강기록 조회 + 소유권 검증. + * 건강기록은 민감정보이므로 본인 기록만 접근할 수 있어야 한다. + */ + private HealthRecord requireOwnedRecord(Long recordId) { + User currentUser = currentUserFacade.requireCurrentUser(); + HealthRecord record = healthRecordRepository.findById(recordId) + .orElseThrow(() -> new HealthRecordNotFoundException("건강 기록을 찾을 수 없습니다: " + recordId)); + + if (record.getUser() == null || !record.getUser().getId().equals(currentUser.getId())) { + // 존재 여부를 숨기기 위해 동일한 404 로 응답한다. + throw new HealthRecordNotFoundException("건강 기록을 찾을 수 없습니다: " + recordId); + } + return record; + } +} diff --git a/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java b/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java new file mode 100644 index 00000000..806f44df --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java @@ -0,0 +1,91 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.ChildNotFoundException; +import com.carecode.domain.health.dto.response.VaccinationScheduleResponse; +import com.carecode.domain.health.entity.VaccinationSchedule; +import com.carecode.domain.health.entity.VaccineType; +import com.carecode.domain.health.repository.VaccinationScheduleRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * 아이별 예방접종 일정 생성·조회. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VaccinationScheduleService { + + private final VaccinationScheduleRepository scheduleRepository; + private final ChildRepository childRepository; + + /** + * 아이의 생년월일을 기준으로 표준 접종 일정을 생성한다. + * 이미 일정이 있으면 중복 생성하지 않는다. + * + * @return 생성된 일정 수 + */ + @Transactional + public int generateScheduleForChild(Long childId) { + Child child = childRepository.findById(childId) + .orElseThrow(() -> new ChildNotFoundException("아이를 찾을 수 없습니다: " + childId)); + + if (child.getBirthDate() == null) { + log.warn("생년월일이 없어 접종 일정을 생성할 수 없습니다. childId={}", childId); + return 0; + } + if (scheduleRepository.existsByChildId(childId)) { + log.debug("이미 접종 일정이 있어 생성을 건너뜁니다. childId={}", childId); + return 0; + } + + List schedules = new ArrayList<>(); + for (VaccineType vaccine : VaccineType.all()) { + for (int dose = 1; dose <= vaccine.getTotalDoses(); dose++) { + LocalDate dueDate = child.getBirthDate().plusMonths(vaccine.getMonthsForDose(dose)); + schedules.add(VaccinationSchedule.builder() + .child(child) + .vaccineType(vaccine) + .doseNumber(dose) + .dueDate(dueDate) + .status(VaccinationSchedule.VaccinationStatus.SCHEDULED) + .build()); + } + } + + scheduleRepository.saveAll(schedules); + log.info("예방접종 일정 생성 완료 - childId={}, 건수={}", childId, schedules.size()); + return schedules.size(); + } + + public List getSchedule(Long childId) { + return scheduleRepository.findByChildIdOrderByDueDateAsc(childId).stream() + .map(VaccinationScheduleResponse::from) + .toList(); + } + + /** 예정일이 지났는데 아직 접종하지 않은 항목. */ + public List getOverdue(Long childId) { + return scheduleRepository.findOverdue(childId, LocalDate.now()).stream() + .map(VaccinationScheduleResponse::from) + .toList(); + } + + @Transactional + public VaccinationScheduleResponse markCompleted(Long scheduleId, LocalDate completedDate) { + VaccinationSchedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new IllegalArgumentException("접종 일정을 찾을 수 없습니다: " + scheduleId)); + + schedule.markCompleted(completedDate != null ? completedDate : LocalDate.now()); + return VaccinationScheduleResponse.from(scheduleRepository.save(schedule)); + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java new file mode 100644 index 00000000..75b93881 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java @@ -0,0 +1,57 @@ +package com.carecode.domain.notification.sender; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +/** + * 이메일 채널 발송기. + */ +@Slf4j +@Component +public class EmailNotificationSender implements NotificationSender { + + private final JavaMailSender mailSender; + private final String fromAddress; + + public EmailNotificationSender(JavaMailSender mailSender, + @Value("${spring.mail.username:}") String fromAddress) { + this.mailSender = mailSender; + this.fromAddress = fromAddress; + } + + @Override + public NotificationChannelType channel() { + return NotificationChannelType.EMAIL; + } + + @Override + public boolean isAvailable() { + return fromAddress != null && !fromAddress.isBlank(); + } + + @Override + public boolean send(NotificationPayload payload) { + String to = payload.resolveEmailAddress(); + if (to == null || to.isBlank()) { + log.warn("이메일 발송 건너뜀 - 수신 주소 없음"); + return false; + } + + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setFrom(fromAddress); + message.setSubject(payload.getTitle()); + message.setText(payload.getMessage()); + mailSender.send(message); + return true; + } catch (Exception e) { + // 한 채널의 실패가 다른 채널 발송을 막지 않도록 예외를 밖으로 던지지 않는다. + log.error("이메일 발송 실패 - 수신자={}", to, e); + return false; + } + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java b/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java new file mode 100644 index 00000000..621626f7 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java @@ -0,0 +1,21 @@ +package com.carecode.domain.notification.sender; + +/** + * 알림 전달 채널. + */ +public enum NotificationChannelType { + IN_APP("인앱"), + EMAIL("이메일"), + PUSH("푸시"), + SMS("SMS"); + + private final String displayName; + + NotificationChannelType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java b/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java new file mode 100644 index 00000000..51d685b3 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java @@ -0,0 +1,115 @@ +package com.carecode.domain.notification.sender; + +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.entity.NotificationPreference; +import com.carecode.domain.notification.repository.NotificationPreferenceRepository; +import com.carecode.domain.user.entity.User; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 알림을 사용자 설정에 맞는 채널로 실제 발송한다. + * + *

이전에는 알림을 DB 에 저장만 하고 {@code deliveryStatus="SENT"} 를 하드코딩해서, + * 채널 설정·디바이스 토큰을 모아두고도 실제로는 아무데도 보내지 않았다. + */ +@Slf4j +@Component +public class NotificationDispatcher { + + private final Map senders = + new EnumMap<>(NotificationChannelType.class); + private final NotificationPreferenceRepository preferenceRepository; + + public NotificationDispatcher(List senderBeans, + NotificationPreferenceRepository preferenceRepository) { + this.preferenceRepository = preferenceRepository; + for (NotificationSender sender : senderBeans) { + senders.put(sender.channel(), sender); + } + log.info("알림 발송 채널 등록: {}", senders.keySet()); + } + + /** + * 저장된 알림을 사용자 설정 채널로 발송한다. + * + *

요청 스레드를 막지 않도록 비동기로 실행한다. 발송 실패는 로그로 남기고 + * 다른 채널 발송을 계속한다. + * + * @return 하나 이상의 채널로 발송에 성공했는지 여부 + */ + @Async("notificationExecutor") + public void dispatchAsync(Notification notification) { + dispatch(notification); + } + + public boolean dispatch(Notification notification) { + User recipient = notification.getUser(); + if (recipient == null) { + log.warn("알림 발송 건너뜀 - 수신자 없음. notificationId={}", notification.getId()); + return false; + } + + NotificationPreference preference = preferenceRepository + .findByUserAndNotificationType(recipient, notification.getNotificationType()) + .orElse(null); + + NotificationPayload payload = NotificationPayload.builder() + .recipient(recipient) + .title(notification.getTitle()) + .message(notification.getMessage()) + .emailAddress(preference != null ? preference.getEmailAddress() : null) + .deviceToken(preference != null ? preference.getDeviceToken() : null) + .phoneNumber(preference != null ? preference.getPhoneNumber() : null) + .build(); + + boolean anySent = false; + for (NotificationChannelType channel : NotificationChannelType.values()) { + if (!isChannelEnabled(preference, channel)) { + continue; + } + // 인앱은 알림 레코드 자체가 전달 수단이므로 별도 발송기가 없다. + if (channel == NotificationChannelType.IN_APP) { + anySent = true; + continue; + } + + NotificationSender sender = senders.get(channel); + if (sender == null || !sender.isAvailable()) { + continue; + } + + boolean sent = sender.send(payload); + anySent |= sent; + log.debug("알림 발송 결과 - notificationId={}, channel={}, success={}", + notification.getId(), channel, sent); + } + + return anySent; + } + + /** + * 채널 사용 여부. 사용자 설정이 없으면 인앱과 푸시를 기본으로 켠다. + */ + private boolean isChannelEnabled(NotificationPreference preference, NotificationChannelType channel) { + if (preference == null) { + return channel == NotificationChannelType.IN_APP || channel == NotificationChannelType.PUSH; + } + return switch (channel) { + case IN_APP -> Boolean.TRUE.equals(preference.getInAppEnabled()); + case EMAIL -> Boolean.TRUE.equals(preference.getEmailEnabled()); + case PUSH -> Boolean.TRUE.equals(preference.getPushEnabled()); + case SMS -> Boolean.TRUE.equals(preference.getSmsEnabled()); + }; + } + + Optional senderFor(NotificationChannelType channel) { + return Optional.ofNullable(senders.get(channel)); + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java b/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java new file mode 100644 index 00000000..01b29ef4 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java @@ -0,0 +1,40 @@ +package com.carecode.domain.notification.sender; + +import com.carecode.domain.user.entity.User; +import lombok.Builder; +import lombok.Getter; + +/** + * 채널 구현체에 전달되는 발송 요청. + */ +@Getter +@Builder +public class NotificationPayload { + + private final User recipient; + private final String title; + private final String message; + + /** 이메일 수신 주소. 없으면 사용자 계정 이메일을 사용한다. */ + private final String emailAddress; + + /** 푸시 발송 대상 디바이스 토큰. */ + private final String deviceToken; + + /** SMS 수신 번호. */ + private final String phoneNumber; + + public String resolveEmailAddress() { + if (emailAddress != null && !emailAddress.isBlank()) { + return emailAddress; + } + return recipient != null ? recipient.getEmail() : null; + } + + public String resolvePhoneNumber() { + if (phoneNumber != null && !phoneNumber.isBlank()) { + return phoneNumber; + } + return recipient != null ? recipient.getPhoneNumber() : null; + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java new file mode 100644 index 00000000..99b3a095 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java @@ -0,0 +1,28 @@ +package com.carecode.domain.notification.sender; + +/** + * 채널별 알림 발송 구현체. + * + *

구현체를 빈으로 등록하면 {@code NotificationDispatcher} 가 자동으로 수집한다. + * 새 채널(카카오 알림톡 등)을 붙일 때는 이 인터페이스만 구현하면 된다. + */ +public interface NotificationSender { + + NotificationChannelType channel(); + + /** + * 발송을 시도한다. + * + * @return 발송 성공 여부. 실패 시 예외를 던지지 말고 false 를 반환한다 + * (한 채널 실패가 다른 채널 발송을 막지 않도록). + */ + boolean send(NotificationPayload payload); + + /** + * 현재 설정으로 이 채널을 실제로 사용할 수 있는지. + * 자격증명이 없으면 false 를 반환해 조용히 건너뛴다. + */ + default boolean isAvailable() { + return true; + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java new file mode 100644 index 00000000..0820b9af --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java @@ -0,0 +1,64 @@ +package com.carecode.domain.notification.sender; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +/** + * FCM 푸시 채널 발송기. + * + *

{@link FirebaseMessaging} 빈이 없으면(자격증명 미설정) 비활성 상태로 동작한다. + */ +@Slf4j +@Component +public class PushNotificationSender implements NotificationSender { + + private final FirebaseMessaging firebaseMessaging; + + public PushNotificationSender(@Autowired(required = false) @Nullable FirebaseMessaging firebaseMessaging) { + this.firebaseMessaging = firebaseMessaging; + } + + @Override + public NotificationChannelType channel() { + return NotificationChannelType.PUSH; + } + + @Override + public boolean isAvailable() { + return firebaseMessaging != null; + } + + @Override + public boolean send(NotificationPayload payload) { + if (!isAvailable()) { + log.debug("푸시 발송 건너뜀 - FCM 미설정"); + return false; + } + + String token = payload.getDeviceToken(); + if (token == null || token.isBlank()) { + log.debug("푸시 발송 건너뜀 - 디바이스 토큰 없음"); + return false; + } + + try { + Message message = Message.builder() + .setToken(token) + .setNotification(com.google.firebase.messaging.Notification.builder() + .setTitle(payload.getTitle()) + .setBody(payload.getMessage()) + .build()) + .build(); + + firebaseMessaging.send(message); + return true; + } catch (Exception e) { + log.error("푸시 발송 실패 - token={}...", token.length() > 8 ? token.substring(0, 8) : token, e); + return false; + } + } +} diff --git a/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java new file mode 100644 index 00000000..aa74eb3d --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java @@ -0,0 +1,44 @@ +package com.carecode.domain.notification.sender; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * SMS 채널 발송기. + * + *

아직 계약된 SMS 사업자가 없어 실제 전송은 하지 않는다. + * 채널 배선은 완성해 두고, 사업자가 정해지면 {@link #send} 구현만 채우면 된다. + * 설정이 없는 동안에는 {@link #isAvailable()} 이 false 라 디스패처가 건너뛴다. + */ +@Slf4j +@Component +public class SmsNotificationSender implements NotificationSender { + + private final boolean enabled; + + public SmsNotificationSender(@Value("${app.notification.sms.enabled:false}") boolean enabled) { + this.enabled = enabled; + } + + @Override + public NotificationChannelType channel() { + return NotificationChannelType.SMS; + } + + @Override + public boolean isAvailable() { + return enabled; + } + + @Override + public boolean send(NotificationPayload payload) { + String to = payload.resolvePhoneNumber(); + if (to == null || to.isBlank()) { + return false; + } + // 사업자 연동 전까지는 발송을 시도하지 않고 실패로 보고한다. + log.warn("SMS 발송 미구현 - 사업자 연동 필요 (수신번호 보유 여부: {})", true); + return false; + } +} diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java new file mode 100644 index 00000000..1b444f8a --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java @@ -0,0 +1,53 @@ +package com.carecode.domain.notification.service; + +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 시스템이 발생시키는 알림 생성 경로. + * + *

{@link NotificationService#createNotification} 은 "본인이 본인에게" 만드는 사용자 API 라 + * 배치·스케줄러가 쓸 수 없다. 여기서는 액터 검증 없이 대상 사용자에게 직접 알림을 만든다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationCreationService { + + private final NotificationRepository notificationRepository; + private final NotificationDispatcher notificationDispatcher; + + /** + * 알림을 저장하고 사용자 설정 채널로 발송한다. + * + * @return 저장된 알림 + */ + @Transactional + public Notification createAndSend(User recipient, + Notification.NotificationType type, + String title, + String message) { + Notification notification = Notification.builder() + .user(recipient) + .notificationType(type) + .title(title) + .message(message) + .isRead(false) + .build(); + + Notification saved = notificationRepository.save(notification); + + // 배치에서는 발송 결과를 즉시 알 수 있도록 동기 발송한다. + boolean sent = notificationDispatcher.dispatch(saved); + if (!sent) { + log.debug("알림 외부 채널 발송 없음(인앱만) - notificationId={}", saved.getId()); + } + return saved; + } +} 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 02e7dc0f..e0ce6c9c 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationService.java @@ -14,6 +14,7 @@ import com.carecode.domain.notification.entity.Notification; import com.carecode.domain.notification.factory.NotificationStrategyFactory; import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; import com.carecode.domain.notification.strategy.NotificationStrategy; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.UserRepository; @@ -47,7 +48,9 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final UserRepository userRepository; private final NotificationStrategyFactory strategyFactory; - + private final NotificationDispatcher notificationDispatcher; + + // 사용자별 알림 목록 조회 @@ -115,10 +118,13 @@ public NotificationInfoResponse createNotification(NotificationCreateRequest req // 알림 저장 Notification savedNotification = notificationRepository.save(notification); - - // 알림 처리 (전송 등) + + // 알림 처리 (타입별 후처리) strategy.processNotification(savedNotification); - + + // 사용자 설정에 맞는 채널로 실제 발송 (SMTP/FCM 왕복이 요청을 막지 않도록 비동기) + notificationDispatcher.dispatchAsync(savedNotification); + return convertToResponseDto(savedNotification); } catch (Exception e) { log.error("알림 생성 실패: {}", e.getMessage()); diff --git a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java index bb57633c..7803f8e3 100644 --- a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java +++ b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java @@ -1,10 +1,12 @@ package com.carecode.domain.policy.app; import com.carecode.domain.policy.dto.request.PolicySearchRequest; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyBookmarkResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.response.PolicyListResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; +import com.carecode.domain.policy.service.PolicyRecommendationService; import com.carecode.domain.policy.service.PolicyService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,9 +19,10 @@ public class PolicyFacade { private final PolicyService policyService; + private final PolicyRecommendationService policyRecommendationService; @Transactional(readOnly = true) - public List getAllPolicies() { return policyService.getAllPolicies(); } + public List getAllPolicies(int page, int size) { return policyService.getAllPolicies(page, size); } @Transactional(readOnly = true) public PolicyDto getPolicyById(Long policyId) { return policyService.getPolicyById(policyId); } @@ -71,6 +74,11 @@ public List getBookmarks(String userIdOrEmail) { public void removeBookmark(String userIdOrEmail, Long policyId) { policyService.removeBookmark(userIdOrEmail, policyId); } + + @Transactional(readOnly = true) + public List recommendPolicies(int limit) { + return policyRecommendationService.recommendForCurrentUser(limit); + } } diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index ba6dfd22..c29ec226 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -3,9 +3,11 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.controller.BaseController; +import com.carecode.core.util.PageRequestUtil; import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.request.PolicySearchRequest; import com.carecode.domain.policy.dto.response.PolicyListResponse; @@ -46,9 +48,12 @@ public class PolicyController extends BaseController { @GetMapping @LogExecutionTime @Operation(summary = "전체 정책 목록 조회", description = "등록된 모든 육아 정책 목록을 조회합니다.") - public ResponseEntity> getAllPolicies() { + public ResponseEntity> getAllPolicies( + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(required = false) Integer page, + @Parameter(description = "페이지 크기 (최대 200)") @RequestParam(required = false) Integer size) { log.info("전체 정책 목록 조회"); - List policies = policyFacade.getAllPolicies(); + List policies = policyFacade.getAllPolicies( + PageRequestUtil.normalizePage(page), PageRequestUtil.normalizeSize(size)); return ResponseEntity.ok(policies); } @@ -130,10 +135,10 @@ public ResponseEntity> getPoliciesByLocation( // 연령대별 정책 조회 @GetMapping("/age") @LogExecutionTime - @Operation(summary = "연령대별 정책 조회", description = "특정 연령대에 해당하는 육아 정책 목록을 조회합니다.") + @Operation(summary = "연령대별 정책 조회", description = "월령 범위에 해당하는 정책을 조회합니다.") public ResponseEntity> getPoliciesByAgeRange( - @Parameter(description = "최소 연령", required = true) @RequestParam Integer minAge, - @Parameter(description = "최대 연령", required = true) @RequestParam Integer maxAge) { + @Parameter(description = "최소 월령", example = "0", required = true) @RequestParam Integer minAge, + @Parameter(description = "최대 월령", example = "71", required = true) @RequestParam Integer maxAge) { log.info("연령대별 정책 조회: 최소연령={}, 최대연령={}", minAge, maxAge); try { @@ -216,8 +221,9 @@ public ResponseEntity getPolicyStatistics() { // 아이 연령별 정책 조회 @GetMapping("/child-age") @LogExecutionTime - @Operation(summary = "아이 연령별 정책 조회", description = "특정 연령의 아이에게 해당하는 정책을 조회합니다.") - public ResponseEntity> getPoliciesByChildAge(@Parameter(description = "아이 연령", required = true) @RequestParam Integer childAge) { + @Operation(summary = "아이 연령별 정책 조회", description = "해당 월령의 아이가 받을 수 있는 정책을 조회합니다.") + public ResponseEntity> getPoliciesByChildAge( + @Parameter(description = "아이 월령", example = "24", required = true) @RequestParam Integer childAge) { List policies = policyFacade.getPoliciesByChildAge(childAge); return ResponseEntity.ok(policies); @@ -258,6 +264,15 @@ public ResponseEntity removeBookmark( return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("북마크가 삭제되었습니다.").build()); } + // 개인화 정책 추천 + @GetMapping("/recommendations") + @LogExecutionTime + @Operation(summary = "맞춤 정책 추천", description = "자녀 월령과 거주지에 맞는 정책을 추천합니다.") + public ResponseEntity> getRecommendations( + @Parameter(description = "추천 개수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { + return ResponseEntity.ok(policyFacade.recommendPolicies(PageRequestUtil.normalizeSize(limit))); + } + private String getAuthenticatedUserCode() { return currentUserFacade.requireCurrentUserId(); } diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java new file mode 100644 index 00000000..68378979 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java @@ -0,0 +1,15 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 개인화 추천 결과 한 건. 왜 추천됐는지 근거를 함께 내려 준다. */ +@Getter +@Builder +public class PersonalizedPolicyResponse { + private PolicyDto policy; + private int score; + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index c6472de3..751d4ceb 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -41,9 +41,11 @@ public class Policy { @Column(name = "policy_type") private String policyType; + /** 대상 연령 하한(개월). 세 단위 표기는 AgeRangeParser 로 환산해 넣는다. */ @Column(name = "target_age_min") private Integer targetAgeMin; - + + /** 대상 연령 상한(개월). null 이면 상한 없음. */ @Column(name = "target_age_max") private Integer targetAgeMax; diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java index a82b0f49..326bae72 100644 --- a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -25,6 +26,9 @@ public interface PolicyRepository extends JpaRepository { // 활성화된 정책 목록 조회 List findByIsActiveTrue(); + /** 추천 후보 조회. 우선순위 높은 정책부터 가져와 상위 N건만 채점한다. */ + Page findByIsActiveTrueOrderByPriorityDescViewCountDesc(Pageable pageable); + // 정책 유형별 조회 List findByPolicyType(String policyType); @@ -104,9 +108,27 @@ Page findBySearchCriteria(@Param("keyword") String keyword, @Param("cate // 카테고리별 통계 조회 - @Query("SELECT new com.carecode.domain.policy.dto.PolicyCategoryStatsResponse(" + + // HQL 의 생성자 표현식은 완전한 패키지 경로를 요구한다. + // 실제 클래스는 dto.response 패키지에 있으므로 경로가 틀리면 기동 시점에 실패한다. + @Query("SELECT new com.carecode.domain.policy.dto.response.PolicyCategoryStatsResponse(" + "p.policyType, COUNT(p), 0.0, 0) " + "FROM Policy p WHERE p.isActive = true " + "GROUP BY p.policyType") List getCategoryStats(); + + /** + * 조회수를 DB 에서 원자적으로 증가시킨다. + * 엔티티를 읽어 +1 후 save 하면 동시 요청 시 증가분이 유실된다(lost update). + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Policy p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :policyId") + int incrementViewCount(@Param("policyId") Long policyId); + + /** + * 중복 없는 정책 유형 목록. 전체 행을 메모리로 올려 distinct 하지 않는다. + */ + @Query("SELECT DISTINCT p.policyType FROM Policy p " + + "WHERE p.policyType IS NOT NULL AND p.policyType <> '' " + + "ORDER BY p.policyType") + List findDistinctPolicyTypes(); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java new file mode 100644 index 00000000..e0b0e2af --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -0,0 +1,141 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.mapper.PolicyMapper; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** 아이 월령과 거주지로 정책을 추천한다. 협업 필터링은 사용자 로그가 쌓인 뒤에나 의미가 있다. */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyRecommendationService { + + private static final int CANDIDATE_SIZE = 200; + private static final int SCORE_AGE_MATCH = 5; + private static final int SCORE_REGION_MATCH = 3; + private static final int SCORE_DEADLINE_SOON = 2; + private static final int DEADLINE_SOON_DAYS = 30; + + private final PolicyRepository policyRepository; + private final ChildRepository childRepository; + private final PolicyMapper policyMapper; + private final CurrentUserFacade currentUserFacade; + + /** 로그인 사용자에게 맞는 정책을 점수 순으로 반환한다. */ + public List recommendForCurrentUser(int limit) { + User user = currentUserFacade.requireCurrentUser(); + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + LocalDate today = LocalDate.now(); + + List candidates = policyRepository + .findByIsActiveTrueOrderByPriorityDescViewCountDesc(PageRequest.of(0, CANDIDATE_SIZE)) + .getContent(); + + List scored = new ArrayList<>(); + for (Policy policy : candidates) { + if (isExpired(policy, today)) { + continue; + } + List reasons = new ArrayList<>(); + int score = score(policy, user, children, today, reasons); + if (score <= 0) { + continue; + } + scored.add(PersonalizedPolicyResponse.builder() + .policy(policyMapper.toResponse(policy)) + .score(score) + .reasons(reasons) + .build()); + } + + scored.sort(Comparator.comparingInt(PersonalizedPolicyResponse::getScore).reversed()); + return scored.size() > limit ? scored.subList(0, limit) : scored; + } + + /** 연령 조건이 있는데 맞는 아이가 없으면 0점으로 제외한다. */ + private int score(Policy policy, User user, List children, LocalDate today, List reasons) { + int score = 1; // 조건 없는 범용 정책도 노출되도록 하는 기본 점수 + + boolean hasAgeCondition = policy.getTargetAgeMin() != null || policy.getTargetAgeMax() != null; + if (hasAgeCondition) { + Child matched = children.stream().filter(c -> matchesAge(policy, c, today)).findFirst().orElse(null); + if (matched == null) { + return 0; + } + score += SCORE_AGE_MATCH; + reasons.add(matched.getName() + " 연령 조건에 해당합니다."); + } + + if (matchesRegion(policy, user, reasons)) { + score += SCORE_REGION_MATCH; + } + + if (isDeadlineNear(policy, today)) { + score += SCORE_DEADLINE_SOON; + reasons.add("신청 마감이 " + DEADLINE_SOON_DAYS + "일 이내입니다."); + } + + Integer priority = policy.getPriority(); + if (priority != null && priority > 0) { + score += Math.min(priority, 3); + } + return score; + } + + /** 정책 대상 월령과 아이의 월령을 비교한다. 시드 데이터 기준 targetAge 단위는 개월이다. */ + private boolean matchesAge(Policy policy, Child child, LocalDate today) { + if (child.getBirthDate() == null) { + return false; + } + long months = ChronoUnit.MONTHS.between(child.getBirthDate(), today); + Integer min = policy.getTargetAgeMin(); + Integer max = policy.getTargetAgeMax(); + return (min == null || months >= min) && (max == null || months <= max); + } + + /** 정책 대상 지역이 사용자 주소와 겹치는지. 전국 정책은 항상 일치로 본다. */ + private boolean matchesRegion(Policy policy, User user, List reasons) { + String region = policy.getTargetRegion(); + if (region == null || region.isBlank() || region.contains("전국")) { + reasons.add("전국 어디서나 신청할 수 있습니다."); + return true; + } + String address = user.getAddress(); + if (address == null || address.isBlank()) { + return false; + } + // 주소는 "서울특별시 강남구...", 대상 지역은 "서울" 처럼 표기 단위가 달라 양방향으로 확인한다. + if (address.contains(region) || region.contains(address)) { + reasons.add(region + " 거주자 대상 정책입니다."); + return true; + } + return false; + } + + private boolean isExpired(Policy policy, LocalDate today) { + LocalDate end = policy.getApplicationEndDate(); + return end != null && end.isBefore(today); + } + + private boolean isDeadlineNear(Policy policy, LocalDate today) { + LocalDate end = policy.getApplicationEndDate(); + return end != null && !end.isBefore(today) && ChronoUnit.DAYS.between(today, end) <= DEADLINE_SOON_DAYS; + } +} diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyService.java b/src/main/java/com/carecode/domain/policy/service/PolicyService.java index 9f609be9..0511d467 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyService.java @@ -1,6 +1,6 @@ package com.carecode.domain.policy.service; -import com.carecode.core.annotation.CacheableResult; +import org.springframework.cache.annotation.Cacheable; import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.exception.PolicyNotFoundException; import com.carecode.core.util.SortUtil; @@ -48,12 +48,16 @@ public class PolicyService { // 정책 목록 조회 + /** + * 정책 목록 조회. + *

테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. + */ @LogExecutionTime - public List getAllPolicies() { - log.info("전체 정책 목록 조회"); - - List policies = policyRepository.findAll(); - return policies.stream() + public List getAllPolicies(int page, int size) { + log.info("전체 정책 목록 조회 - page={}, size={}", page, size); + + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return policyRepository.findAll(pageable).getContent().stream() .map(policyMapper::toResponse) .collect(Collectors.toList()); } @@ -62,7 +66,7 @@ public List getAllPolicies() { // 정책 상세 조회 @LogExecutionTime - @CacheableResult(cacheName = "policy", key = "#policyId") + @Cacheable(cacheNames = "policy", key = "#policyId") public PolicyDto getPolicyById(Long policyId) { log.info("정책 상세 조회: 정책ID={}", policyId); @@ -197,12 +201,11 @@ public List getLatestPolicies(int limit) { @Transactional public void incrementViewCount(Long policyId) { log.info("정책 조회수 증가: 정책ID={}", policyId); - Policy policy = policyRepository.findById(policyId) - .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + policyId)); - - int currentViewCount = policy.getViewCount() != null ? policy.getViewCount() : 0; - policy.setViewCount(currentViewCount + 1); - policyRepository.save(policy); + // DB 에서 원자적으로 증가시킨다. 갱신된 행이 없으면 존재하지 않는 정책이다. + int updated = policyRepository.incrementViewCount(policyId); + if (updated == 0) { + throw new PolicyNotFoundException("정책을 찾을 수 없습니다: " + policyId); + } } @@ -211,13 +214,8 @@ public void incrementViewCount(Long policyId) { @LogExecutionTime public List getPolicyCategories() { log.info("정책 카테고리 목록 조회"); - - return policyRepository.findAll().stream() - .map(Policy::getPolicyType) - .distinct() - .filter(type -> type != null && !type.trim().isEmpty()) - .sorted() - .collect(Collectors.toList()); + // 전체 정책을 메모리로 올려 distinct 하던 것을 DB DISTINCT 쿼리로 대체 + return policyRepository.findDistinctPolicyTypes(); } diff --git a/src/main/java/com/carecode/domain/user/controller/AuthController.java b/src/main/java/com/carecode/domain/user/controller/AuthController.java index 1e53cf03..756879f8 100644 --- a/src/main/java/com/carecode/domain/user/controller/AuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/AuthController.java @@ -57,23 +57,18 @@ public class AuthController extends BaseController { @RateLimit(requests = 5, windowSeconds = 60, message = "로그인 시도 횟수를 초과했습니다. 1분 후 다시 시도해주세요.") @Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인합니다.") public ResponseEntity login(@Parameter(description = "로그인 정보", required = true) @Valid @RequestBody LoginRequestDto request) { - User userEntity = userService.getUserEntityByEmail(request.getEmail()); - - // 비밀번호 검증 - if (!passwordEncoder.matches(request.getPassword(), userEntity.getPassword())) { - log.warn("로그인 실패: 비밀번호 불일치 - 이메일={}", request.getEmail()); - return ResponseEntity.status(401).body(TokenDto.builder() - .success(false) - .message("비밀번호가 일치하지 않습니다.") - .build()); - } - - // 사용자 활성 상태 확인 - if (!userEntity.getIsActive()) { - log.warn("로그인 실패: 비활성 사용자 - 이메일={}", request.getEmail()); + // 가입 여부가 응답으로 새어나가지 않도록, 실패 사유와 무관하게 동일한 401 을 반환한다. + // (미가입 404 / 비밀번호 불일치 401 로 나뉘면 이메일 열거가 가능하다.) + User userEntity = userService.findActiveUserEntityByEmail(request.getEmail()).orElse(null); + + if (userEntity == null + || userEntity.getPassword() == null + || !passwordEncoder.matches(request.getPassword(), userEntity.getPassword()) + || !Boolean.TRUE.equals(userEntity.getIsActive())) { + log.warn("로그인 실패 - 이메일={}", request.getEmail()); return ResponseEntity.status(401).body(TokenDto.builder() .success(false) - .message("비활성화된 사용자입니다.") + .message("이메일 또는 비밀번호가 올바르지 않습니다.") .build()); } diff --git a/src/main/java/com/carecode/domain/user/controller/PrivacyController.java b/src/main/java/com/carecode/domain/user/controller/PrivacyController.java new file mode 100644 index 00000000..f2fb51f9 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/controller/PrivacyController.java @@ -0,0 +1,72 @@ +package com.carecode.domain.user.controller; + +import com.carecode.core.annotation.LogExecutionTime; +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.service.PrivacyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 개인정보 관련 API: 동의 관리, 내 데이터 열람, 회원 탈퇴. + */ +@RestController +@RequestMapping("/users/privacy") +@RequiredArgsConstructor +@Tag(name = "개인정보", description = "동의 이력, 데이터 열람 및 파기 API") +public class PrivacyController { + + private final PrivacyService privacyService; + private final ClientIpResolver clientIpResolver; + + @GetMapping("/consents") + @LogExecutionTime + @Operation(summary = "동의 상태 조회", description = "항목별 현재 동의 여부와 동의한 약관 버전을 반환합니다.") + public ResponseEntity getConsents() { + return ResponseEntity.ok(privacyService.getConsentStatus()); + } + + @PostMapping("/consents") + @LogExecutionTime + @Operation(summary = "동의/철회 기록", + description = "동의 이력은 덮어쓰지 않고 새 기록으로 남습니다.") + public ResponseEntity updateConsent( + @Valid @RequestBody ConsentUpdateRequest request, + HttpServletRequest httpRequest) { + return ResponseEntity.ok( + privacyService.recordConsent(request, clientIpResolver.resolve(httpRequest))); + } + + @GetMapping("/consents/history") + @LogExecutionTime + @Operation(summary = "동의 이력 전체 조회") + public ResponseEntity> getConsentHistory() { + return ResponseEntity.ok(privacyService.getConsentHistory()); + } + + @GetMapping("/export") + @LogExecutionTime + @Operation(summary = "내 데이터 내려받기", + description = "개인정보 열람권 행사를 위한 데이터 export. 인증 정보는 포함하지 않습니다.") + public ResponseEntity> exportMyData() { + return ResponseEntity.ok(privacyService.exportMyData()); + } + + @DeleteMapping("/account") + @LogExecutionTime + @Operation(summary = "회원 탈퇴", + description = "개인 식별 정보를 익명화하고 계정을 비활성화합니다.") + public ResponseEntity deleteAccount() { + privacyService.deleteMyAccount(); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java b/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java new file mode 100644 index 00000000..01a9d7a6 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java @@ -0,0 +1,25 @@ +package com.carecode.domain.user.dto.request; + +import com.carecode.domain.user.entity.ConsentType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 동의/철회 요청. + */ +@Getter +@Setter +@NoArgsConstructor +public class ConsentUpdateRequest { + + @NotNull(message = "동의 항목은 필수입니다") + private ConsentType consentType; + + @NotBlank(message = "약관 버전은 필수입니다") + private String policyVersion; + + private boolean granted; +} diff --git a/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java b/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java new file mode 100644 index 00000000..a1e8d45b --- /dev/null +++ b/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java @@ -0,0 +1,38 @@ +package com.carecode.domain.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 항목별 현재 동의 상태. + */ +@Getter +@Builder +public class ConsentStatusResponse { + + private final List consents; + + @Getter + @Builder + public static class ConsentItem { + private final String consentType; + private final String displayName; + private final boolean required; + private final boolean granted; + private final String policyVersion; + private final LocalDateTime updatedAt; + } + + @Getter + @Builder + public static class ConsentHistoryItem { + private final String consentType; + private final String displayName; + private final boolean granted; + private final String policyVersion; + private final LocalDateTime createdAt; + } +} diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserDto.java b/src/main/java/com/carecode/domain/user/dto/response/UserDto.java index 9bca5c8b..ac3cabcc 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserDto.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserDto.java @@ -1,5 +1,6 @@ package com.carecode.domain.user.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.Setter; import lombok.NoArgsConstructor; @@ -22,7 +23,13 @@ public class UserDto { private Long id; private String userId; private String email; - private String password; // 생성 시에만 사용 + + /** + * 회원가입/수정 요청에서만 사용한다. + * WRITE_ONLY 로 두지 않으면 이 DTO 를 반환하는 모든 응답에 비밀번호 해시가 실려 나간다. + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; private String name; private String phoneNumber; private LocalDate birthDate; diff --git a/src/main/java/com/carecode/domain/user/entity/ConsentType.java b/src/main/java/com/carecode/domain/user/entity/ConsentType.java new file mode 100644 index 00000000..e259065d --- /dev/null +++ b/src/main/java/com/carecode/domain/user/entity/ConsentType.java @@ -0,0 +1,32 @@ +package com.carecode.domain.user.entity; + +/** + * 동의 항목. + * + *

필수 항목은 미동의 시 서비스 이용이 불가하고, 선택 항목은 언제든 철회할 수 있다. + * 아동 정보 수집은 보호자 동의가 별도로 필요하다. + */ +public enum ConsentType { + + TERMS_OF_SERVICE("서비스 이용약관", true), + PRIVACY_POLICY("개인정보 수집·이용", true), + CHILD_DATA("자녀 정보 수집에 대한 보호자 동의", true), + MARKETING("마케팅 정보 수신", false), + THIRD_PARTY_SHARING("제3자 정보 제공", false); + + private final String displayName; + private final boolean required; + + ConsentType(String displayName, boolean required) { + this.displayName = displayName; + this.required = required; + } + + public String getDisplayName() { + return displayName; + } + + public boolean isRequired() { + return required; + } +} diff --git a/src/main/java/com/carecode/domain/user/entity/UserConsent.java b/src/main/java/com/carecode/domain/user/entity/UserConsent.java new file mode 100644 index 00000000..7b7c7095 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/entity/UserConsent.java @@ -0,0 +1,61 @@ +package com.carecode.domain.user.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 동의 이력. + * + *

개인정보보호법상 "언제, 어떤 버전의 약관에, 무엇을 동의했는지" 를 입증할 수 있어야 한다. + * 따라서 현재 상태를 덮어쓰지 않고 동의·철회를 각각 새 행으로 남긴다(append-only). + */ +@Entity +@Table( + name = "TBL_USER_CONSENT", + indexes = { + @Index(name = "idx_consent_user", columnList = "user_id"), + @Index(name = "idx_consent_user_type", columnList = "user_id,consent_type") + } +) +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserConsent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "consent_type", nullable = false, length = 40) + private ConsentType consentType; + + /** 동의한 약관 버전. 약관이 개정되면 재동의를 받아야 한다. */ + @Column(name = "policy_version", nullable = false, length = 20) + private String policyVersion; + + @Column(name = "granted", nullable = false) + private boolean granted; + + /** 동의 시점의 접속 IP. 분쟁 시 입증 자료로 쓰인다. */ + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java b/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java index f8c7fc15..9dc800c8 100644 --- a/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java +++ b/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java @@ -2,8 +2,21 @@ import com.carecode.domain.user.entity.EmailVerificationToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; import java.util.Optional; public interface EmailVerificationTokenRepository extends JpaRepository { Optional findByToken(String token); + + /** + * 만료되었거나 이미 사용된 토큰을 정리한다. + * 정리하지 않으면 테이블이 무한히 커진다. + */ + @Modifying + @Query("DELETE FROM EmailVerificationToken t WHERE t.expiryDate < :threshold OR t.used = true") + int deleteExpiredOrUsed(@Param("threshold") LocalDateTime threshold); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java b/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java new file mode 100644 index 00000000..b0c68360 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java @@ -0,0 +1,25 @@ +package com.carecode.domain.user.repository; + +import com.carecode.domain.user.entity.ConsentType; +import com.carecode.domain.user.entity.UserConsent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface UserConsentRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(Long userId); + + /** + * 항목별 가장 최근 동의 이력. + * 이력은 append-only 라서 현재 동의 상태는 최신 행으로 판단한다. + */ + @Query("SELECT uc FROM UserConsent uc " + + "WHERE uc.user.id = :userId AND uc.consentType = :consentType " + + "ORDER BY uc.createdAt DESC LIMIT 1") + Optional findLatest(@Param("userId") Long userId, + @Param("consentType") ConsentType consentType); +} diff --git a/src/main/java/com/carecode/domain/user/service/JwtService.java b/src/main/java/com/carecode/domain/user/service/JwtService.java index 0966262b..ee7e3eed 100644 --- a/src/main/java/com/carecode/domain/user/service/JwtService.java +++ b/src/main/java/com/carecode/domain/user/service/JwtService.java @@ -1,5 +1,7 @@ package com.carecode.domain.user.service; +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; import com.carecode.domain.user.dto.response.TokenDto; import com.carecode.domain.user.dto.response.TokenValidationResponse; import io.jsonwebtoken.*; @@ -10,9 +12,11 @@ import org.springframework.stereotype.Service; import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.UUID; /** * JWT 토큰 서비스 @@ -22,6 +26,11 @@ @Service public class JwtService { + /** 토큰 종류를 구분하는 클레임. Access Token 을 Refresh 로, 혹은 그 반대로 쓰는 것을 막습니다. */ + public static final String CLAIM_TOKEN_TYPE = "typ"; + public static final String TOKEN_TYPE_ACCESS = "access"; + public static final String TOKEN_TYPE_REFRESH = "refresh"; + @Value("${jwt.secret}") private String secret; @@ -31,6 +40,9 @@ public class JwtService { @Value("${jwt.refresh-token.expiration:2592000000}") // 30일 private long refreshTokenExpiration; + @Value("${jwt.issuer:carecode}") + private String issuer; + public long getAccessTokenExpirationMs() { return accessTokenExpiration; } @@ -50,38 +62,40 @@ public void validateJwtSecret() { } private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(secret.getBytes()); + // 플랫폼 기본 인코딩에 따라 키가 달라지지 않도록 UTF-8 을 명시합니다. + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } // Access Token 생성 public String generateAccessToken(String userId, String email, String role) { - return generateToken(userId, email, role, null, accessTokenExpiration); + return generateToken(TOKEN_TYPE_ACCESS, userId, email, role, null, accessTokenExpiration); } // Access Token 생성 (name 포함) public String generateAccessToken(String userId, String email, String role, String name) { - return generateToken(userId, email, role, name, accessTokenExpiration); + return generateToken(TOKEN_TYPE_ACCESS, userId, email, role, name, accessTokenExpiration); } // Refresh Token 생성 public String generateRefreshToken(String userId, String email) { - return generateToken(userId, email, null, null, refreshTokenExpiration); + return generateToken(TOKEN_TYPE_REFRESH, userId, email, null, null, refreshTokenExpiration); } // 토큰 생성 - private String generateToken(String userId, String email, String role, String name, long expiration) { + private String generateToken(String tokenType, String userId, String email, String role, String name, long expiration) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); Map claims = new HashMap<>(); + claims.put(CLAIM_TOKEN_TYPE, tokenType); claims.put("userId", userId); claims.put("email", email); if (role != null) { @@ -93,6 +107,8 @@ private String generateToken(String userId, String email, String role, String na return Jwts.builder() .setClaims(claims) + .setId(UUID.randomUUID().toString()) + .setIssuer(issuer) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) @@ -100,6 +116,13 @@ private String generateToken(String userId, String email, String role, String na } + // 토큰에서 종류(access/refresh) 추출 + + public String getTokenType(String token) { + return getClaimFromToken(token, CLAIM_TOKEN_TYPE, String.class); + } + + // 토큰에서 사용자 ID 추출 public String getUserIdFromToken(String token) { @@ -179,6 +202,7 @@ public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) + .requireIssuer(issuer) .build() .parseClaimsJws(token); return !isTokenExpired(token); @@ -189,6 +213,37 @@ public boolean validateToken(String token) { } + // Access Token 전용 검증 - typ=access 인 토큰만 통과시킵니다. + + public boolean validateAccessToken(String token) { + return validateTokenOfType(token, TOKEN_TYPE_ACCESS); + } + + + // Refresh Token 전용 검증 - typ=refresh 인 토큰만 통과시킵니다. + + public boolean validateRefreshToken(String token) { + return validateTokenOfType(token, TOKEN_TYPE_REFRESH); + } + + private boolean validateTokenOfType(String token, String expectedType) { + if (!validateToken(token)) { + return false; + } + try { + String actualType = getTokenType(token); + if (!expectedType.equals(actualType)) { + log.warn("JWT 토큰 종류 불일치: 기대={}, 실제={}", expectedType, actualType); + return false; + } + return true; + } catch (JwtException | IllegalArgumentException e) { + log.warn("JWT 토큰 종류 확인 실패: {}", e.getMessage()); + return false; + } + } + + // 토큰 검증 및 정보 추출 public TokenValidationResponse validateTokenAndExtractInfo(String token) { @@ -224,34 +279,30 @@ public TokenValidationResponse validateTokenAndExtractInfo(String token) { // 토큰 갱신 public TokenDto refreshTokens(String refreshToken) { - try { - if (!validateToken(refreshToken)) { - throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다."); - } - - String userId = getUserIdFromToken(refreshToken); - String email = getEmailFromToken(refreshToken); - String role = getRoleFromToken(refreshToken); - - // 새로운 Access Token과 Refresh Token 생성 - String newAccessToken = generateAccessToken(userId, email, role != null ? role : "PARENT"); - String newRefreshToken = generateRefreshToken(userId, email); - - return TokenDto.builder() - .accessToken(newAccessToken) - .refreshToken(newRefreshToken) - .tokenType("Bearer") - .expiresIn(accessTokenExpiration) - .userId(userId) - .email(email) - .role(role != null ? role : "PARENT") - .success(true) - .message("토큰 갱신 성공") - .build(); - } catch (Exception e) { - log.warn("토큰 갱신 실패: {}", e.getMessage()); - throw new RuntimeException("토큰 갱신에 실패했습니다.", e); + // Access Token 을 Refresh 엔드포인트로 재사용하는 것을 차단합니다. + if (!validateRefreshToken(refreshToken)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."); } + + String userId = getUserIdFromToken(refreshToken); + String email = getEmailFromToken(refreshToken); + String role = getRoleFromToken(refreshToken); + + // 새로운 Access Token과 Refresh Token 생성 + String newAccessToken = generateAccessToken(userId, email, role != null ? role : "PARENT"); + String newRefreshToken = generateRefreshToken(userId, email); + + return TokenDto.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .tokenType("Bearer") + .expiresIn(accessTokenExpiration) + .userId(userId) + .email(email) + .role(role != null ? role : "PARENT") + .success(true) + .message("토큰 갱신 성공") + .build(); } diff --git a/src/main/java/com/carecode/domain/user/service/PrivacyService.java b/src/main/java/com/carecode/domain/user/service/PrivacyService.java new file mode 100644 index 00000000..f4d95dae --- /dev/null +++ b/src/main/java/com/carecode/domain/user/service/PrivacyService.java @@ -0,0 +1,169 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.community.repository.PostRepository; +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.entity.ConsentType; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserConsent; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserConsentRepository; +import com.carecode.domain.user.repository.UserRepository; +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 개인정보 관련 기능: 동의 이력 관리, 내 데이터 열람, 파기. + * + *

개인정보보호법상 정보주체는 자신의 정보를 열람하고 처리 정지·삭제를 요구할 수 있다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PrivacyService { + + private final UserConsentRepository consentRepository; + private final UserRepository userRepository; + private final ChildRepository childRepository; + private final HealthRecordRepository healthRecordRepository; + private final PostRepository postRepository; + private final CurrentUserFacade currentUserFacade; + + // ==================== 동의 관리 ==================== + + /** + * 동의 상태를 기록한다. + * + *

기존 행을 수정하지 않고 새 이력을 남긴다. 언제 무엇에 동의/철회했는지 추적해야 하기 때문이다. + */ + @Transactional + public ConsentStatusResponse recordConsent(ConsentUpdateRequest request, String ipAddress) { + User user = currentUserFacade.requireCurrentUser(); + + if (request.getConsentType().isRequired() && !request.isGranted()) { + log.info("필수 항목 철회 요청 - userId={}, type={}", user.getId(), request.getConsentType()); + } + + UserConsent consent = UserConsent.builder() + .user(user) + .consentType(request.getConsentType()) + .policyVersion(request.getPolicyVersion()) + .granted(request.isGranted()) + .ipAddress(ipAddress) + .build(); + + consentRepository.save(consent); + return getConsentStatus(); + } + + /** 항목별 현재 동의 상태. */ + public ConsentStatusResponse getConsentStatus() { + User user = currentUserFacade.requireCurrentUser(); + + List items = new ArrayList<>(); + for (ConsentType type : ConsentType.values()) { + UserConsent latest = consentRepository.findLatest(user.getId(), type).orElse(null); + items.add(ConsentStatusResponse.ConsentItem.builder() + .consentType(type.name()) + .displayName(type.getDisplayName()) + .required(type.isRequired()) + .granted(latest != null && latest.isGranted()) + .policyVersion(latest != null ? latest.getPolicyVersion() : null) + .updatedAt(latest != null ? latest.getCreatedAt() : null) + .build()); + } + + return ConsentStatusResponse.builder().consents(items).build(); + } + + public List getConsentHistory() { + User user = currentUserFacade.requireCurrentUser(); + return consentRepository.findByUserIdOrderByCreatedAtDesc(user.getId()).stream() + .map(c -> ConsentStatusResponse.ConsentHistoryItem.builder() + .consentType(c.getConsentType().name()) + .displayName(c.getConsentType().getDisplayName()) + .granted(c.isGranted()) + .policyVersion(c.getPolicyVersion()) + .createdAt(c.getCreatedAt()) + .build()) + .toList(); + } + + // ==================== 데이터 열람 ==================== + + /** + * 내 데이터 전체 내려받기. + * + *

정보주체의 열람권 행사에 대응한다. 비밀번호 등 인증 정보는 포함하지 않는다. + */ + public Map 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; + } + + // ==================== 파기 ==================== + + /** + * 회원 탈퇴(파기 요청). + * + *

즉시 물리 삭제하지 않는 이유: 게시글·댓글 등 참조 데이터가 함께 사라지면 + * 다른 이용자의 대화 맥락이 깨지고, 법령상 일정 기간 보존이 필요한 기록도 있다. + * 개인 식별 정보를 익명화하고 soft delete 로 표시한다. + */ + @Transactional + 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()); + + userRepository.save(user); + log.info("회원 탈퇴 처리 완료 - userId={}", user.getId()); + } +} diff --git a/src/main/java/com/carecode/domain/user/service/UserService.java b/src/main/java/com/carecode/domain/user/service/UserService.java index 8909f31e..b11f967c 100644 --- a/src/main/java/com/carecode/domain/user/service/UserService.java +++ b/src/main/java/com/carecode/domain/user/service/UserService.java @@ -99,6 +99,15 @@ public User getUserEntityByEmail(String email) { } + /** + * 예외를 던지지 않는 조회. + * 로그인처럼 "존재하지 않음"과 "비밀번호 불일치"를 구분해서 응답하면 안 되는 경로에서 사용한다. + */ + public Optional findActiveUserEntityByEmail(String email) { + return userRepository.findByEmailAndDeletedAtIsNull(email); + } + + // User 엔티티 저장 @Transactional diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e49edc5b..8cdb176d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,167 +1,44 @@ +# dev 프로파일: 공통 설정은 application.yml 에 있고, 여기에는 차이만 둔다. spring: flyway: enabled: false - # Spring Batch 설정 - batch: - job: - enabled: false - jdbc: - initialize-schema: never - - # Redis 설정 - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - - # 데이터베이스 설정 - datasource: - url: ${DB_URL:jdbc:mariadb://localhost:3306/carecode} - username: ${DB_USERNAME:root} - password: ${DB_PASSWORD} - driver-class-name: org.mariadb.jdbc.Driver - hikari: - maximum-pool-size: 10 - minimum-idle: 5 - idle-timeout: 30000 - connection-timeout: 20000 - max-lifetime: 1800000 - - # JPA & Hibernate 설정 jpa: - database-platform: org.hibernate.dialect.MariaDBDialect hibernate: ddl-auto: update show-sql: true properties: hibernate: - format_sql: true - - # OAuth2 설정 - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET:YOUR_GOOGLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/google" - scope: profile,email - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/kakao" - authorization-grant-type: authorization_code - scope: profile_nickname,profile_image - client-name: Kakao - client-authentication-method: client_secret_post - provider: - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - - # 이메일 설정 - mail: - host: smtp.gmail.com - port: 587 - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true - -# 서버 설정 -server: - port: ${SERVER_PORT:8082} - servlet: - context-path: / + generate_statistics: true -# API 설정 app: - api: - base-url: /api/v1 - version: v1 - title: 맘편한 API - description: 육아 지원 플랫폼 맘편한의 REST API - -# JWT 설정 -jwt: - secret: ${JWT_SECRET} - access-token: - expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} # 1시간 - refresh-token: - expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:2592000000} # 30일 + monitoring: + query-count: + enabled: true + threshold: 20 -# Swagger/OpenAPI 3 설정 springdoc: swagger-ui: enabled: true - path: /swagger-ui.html - operationsSorter: method - tagsSorter: alpha server-url: ${SWAGGER_SERVER_URL:http://localhost:8082} api-docs: - path: /api-docs - packages-to-scan: com.carecode - default-consumes-media-type: application/json - default-produces-media-type: application/json + enabled: true + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env -# 로깅 설정 (dev: verbose) logging: level: root: INFO com.carecode: DEBUG org.springframework.web: DEBUG + org.springframework.security: DEBUG org.hibernate.SQL: DEBUG - org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.stat: DEBUG + com.carecode.core.monitoring: DEBUG pattern: console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" - -# Actuator 설정 (모니터링) -management: - endpoints: - web: - exposure: - include: health,info,metrics,prometheus,env,httptrace - base-path: /actuator - endpoint: - health: - show-details: when-authorized - probes: - enabled: true - metrics: - enabled: true - prometheus: - enabled: true - metrics: - export: - prometheus: - enabled: true - tags: - application: ${spring.application.name} - health: - livenessState: - enabled: true - readinessState: - enabled: true - -# Kakao 설정 -kakao: - redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:3000/auth/kakao/callback} - -# 선택: 리프레시 토큰을 Redis에 등록·검증(jwt.refresh-token.store=redis). 기본은 application.properties의 none. -# jwt: -# refresh-token: -# store: redis - -# 공공 데이터 API 설정 -public: - data: - api: - key: ${PUBLIC_DATA_API_KEY} - base-url: ${PUBLIC_DATA_API_BASE_URL:http://openapi.seoul.go.kr:8088} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index acf4fb9e..a913a6cb 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,166 +1,31 @@ +# prod 프로파일: 공통 설정은 application.yml 에 있고, 여기에는 차이만 둔다. spring: flyway: enabled: true - baseline-on-migrate: true - locations: classpath:db/migration - # Spring Batch 설정 - batch: - job: - enabled: false - jdbc: - initialize-schema: never - - # Redis 설정 - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - - # 데이터베이스 설정 - datasource: - url: ${DB_URL:jdbc:mariadb://localhost:3306/carecode} - username: ${DB_USERNAME:root} - password: ${DB_PASSWORD} - driver-class-name: org.mariadb.jdbc.Driver - hikari: - maximum-pool-size: 10 - minimum-idle: 5 - idle-timeout: 30000 - connection-timeout: 20000 - max-lifetime: 1800000 - - # JPA & Hibernate 설정 jpa: - database-platform: org.hibernate.dialect.MariaDBDialect hibernate: + # 스키마 변경은 Flyway 마이그레이션으로만 수행한다. ddl-auto: validate show-sql: false - properties: - hibernate: - format_sql: true - - # OAuth2 설정 - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET:YOUR_GOOGLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/google" - scope: profile,email - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/kakao" - authorization-grant-type: authorization_code - scope: profile_nickname,profile_image - client-name: Kakao - client-authentication-method: client_secret_post - provider: - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - - # 이메일 설정 - mail: - host: smtp.gmail.com - port: 587 - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true -# 서버 설정 -server: - port: ${SERVER_PORT:8082} - servlet: - context-path: / - -# API 설정 -app: - api: - base-url: /api/v1 - version: v1 - title: 맘편한 API - description: 육아 지원 플랫폼 맘편한의 REST API - -# JWT 설정 -jwt: - secret: ${JWT_SECRET} - access-token: - expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} # 1시간 - refresh-token: - expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:2592000000} # 30일 + datasource: + hikari: + maximum-pool-size: ${DB_POOL_MAX_SIZE:20} + minimum-idle: ${DB_POOL_MIN_IDLE:10} -# Swagger 비활성화 (prod) +# 운영에서는 API 문서를 노출하지 않는다. springdoc: swagger-ui: enabled: false - path: /swagger-ui.html - operationsSorter: method - tagsSorter: alpha - server-url: ${SWAGGER_SERVER_URL:http://localhost:8082} api-docs: enabled: false - path: /api-docs - packages-to-scan: com.carecode - default-consumes-media-type: application/json - default-produces-media-type: application/json -# 로깅 설정 (prod: INFO 기반) logging: level: + root: WARN com.carecode: INFO - com.carecode.core.security: INFO - com.carecode.domain.user.service.JwtService: INFO org.springframework.security: INFO - org.springframework.security.oauth2: INFO org.springframework.web: INFO pattern: console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" - -# Actuator 설정 (모니터링) -management: - endpoints: - web: - exposure: - include: health,info,prometheus - base-path: /actuator - endpoint: - health: - show-details: when-authorized - probes: - enabled: true - metrics: - enabled: true - prometheus: - enabled: true - metrics: - export: - prometheus: - enabled: true - tags: - application: ${spring.application.name} - health: - livenessState: - enabled: true - readinessState: - enabled: true - -# Kakao 설정 -kakao: - redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:3000/auth/kakao/callback} - -# 공공 데이터 API 설정 -public: - data: - api: - key: ${PUBLIC_DATA_API_KEY} - base-url: ${PUBLIC_DATA_API_BASE_URL:http://openapi.seoul.go.kr:8088} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index c7ba16dd..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,147 +0,0 @@ -spring.application.name=CareCode - -# API ?? -app.api.base-url=/api/v1 -app.api.version=v1 -app.api.title=??? API -app.api.description=?? ?? ??? ???? REST API - -# ?? ?? -server.port=${SERVER_PORT:8082} -server.servlet.context-path=/ - -# =============================== -# JWT ?? -# =============================== -# JWT ??? ? (?? ????? ?? ??? ??) -jwt.secret=${JWT_SECRET} -# Access Token ?? ?? (???) - 1?? -jwt.access-token.expiration=${JWT_ACCESS_TOKEN_EXPIRATION:3600000} -# Refresh Token ?? ?? (???) - 30? -jwt.refresh-token.expiration=${JWT_REFRESH_TOKEN_EXPIRATION:2592000000} -# none: JWT 서명만 검증(기존 동작). redis: 리프레시 토큰을 Redis에 등록·회전·로그아웃 시 일괄 폐기. -jwt.refresh-token.store=${JWT_REFRESH_TOKEN_STORE:none} - -# =============================== -# Swagger/OpenAPI 3 ?? -# =============================== -# Swagger UI ??? -springdoc.swagger-ui.enabled=true -springdoc.swagger-ui.path=/swagger-ui.html -springdoc.swagger-ui.operationsSorter=method -springdoc.swagger-ui.tagsSorter=alpha - -# API ?? ?? -springdoc.api-docs.path=/api-docs - -# ??? ?? ?? -springdoc.packages-to-scan=com.carecode - -# ?? ?? (Spring Security? ?? ??) -springdoc.default-consumes-media-type=application/json -springdoc.default-produces-media-type=application/json - -# Swagger ?? URL ?? (?? ???) -springdoc.swagger-ui.server-url=${SWAGGER_SERVER_URL:http://localhost:8082} - -# ?? ?? -logging.level.com.carecode=INFO -logging.level.com.carecode.core.security=INFO -logging.level.com.carecode.domain.user.service.JwtService=DEBUG -logging.level.org.springframework.security=DEBUG -logging.level.org.springframework.security.oauth2=DEBUG -logging.level.org.springframework.web=INFO -logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n - -# Spring Batch ?? ?? ???? -spring.batch.job.enabled=false -spring.batch.jdbc.initialize-schema=never - -# Redis ?? ?? ???? -#spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - -# =============================== -# Redis ?? ?? -# =============================== -spring.redis.host=${REDIS_HOST:localhost} -spring.redis.port=${REDIS_PORT:6379} - -# =============================== -# MariaDB Database Configuration -# =============================== -spring.datasource.url=${DB_URL:jdbc:mariadb://localhost:3306/carecode} -spring.datasource.username=${DB_USERNAME:root} -spring.datasource.password=${DB_PASSWORD} -spring.datasource.driver-class-name=org.mariadb.jdbc.Driver - -# =============================== -# JPA & Hibernate Configuration -# =============================== -# JPA ???? Hibernate ?? -spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect - -# ??? ?? ??/???? ?? -spring.jpa.hibernate.ddl-auto=update - -# SQL ?? ?? -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.format_sql=true - -# Naming ?? (CamelCase ? snake_case) -#spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl -#spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl - -# =============================== -# HikariCP (Connection Pool) ?? -# =============================== -spring.datasource.hikari.maximum-pool-size=10 -spring.datasource.hikari.minimum-idle=5 -spring.datasource.hikari.idle-timeout=30000 -spring.datasource.hikari.connection-timeout=20000 -spring.datasource.hikari.max-lifetime=1800000 - -# =============================== -# OAuth2 ?? ??? ?? -# =============================== -# Google OAuth2 (?? ?? ?) -spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID} -spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:YOUR_GOOGLE_CLIENT_SECRET} -spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/google -spring.security.oauth2.client.registration.google.scope=profile,email - -# Kakao OAuth2 ?? -spring.security.oauth2.client.registration.kakao.client-id=${KAKAO_CLIENT_ID} -spring.security.oauth2.client.registration.kakao.client-secret=${KAKAO_CLIENT_SECRET} -spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/login/oauth2/code/kakao -spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code -spring.security.oauth2.client.registration.kakao.scope=profile_nickname,profile_image -spring.security.oauth2.client.registration.kakao.client-name=Kakao -spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post - -# ??? Redirect URI ?? (??? ??? ??? ??? URI) -kakao.redirect-uri=${KAKAO_REDIRECT_URI:http://localhost:3000/auth/kakao/callback} -# Kakao: active flow is REST (POST /auth/kakao/login + KakaoUtil). registration.kakao.* supplies client credentials only; SecurityConfig does not enable oauth2Login(). - -# Kakao OAuth2 Provider ?? -spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize -spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token -spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me -spring.security.oauth2.client.provider.kakao.user-name-attribute=id - -# =============================== -# ??? ?? SMTP ?? ?? -# =============================== -spring.mail.host=smtp.gmail.com -spring.mail.port=587 -spring.mail.username=${MAIL_USERNAME} -spring.mail.password=${MAIL_PASSWORD} -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true - -# =============================== -# ????? API ?? -# =============================== -# ????? ?? API ? -public.data.api.key=${PUBLIC_DATA_API_KEY} -# ????? ?? ?? URL (??? API) -public.data.api.base-url=${PUBLIC_DATA_API_BASE_URL:http://openapi.seoul.go.kr:8088} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 18b479ec..f548d6ef 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,11 +2,233 @@ spring: application: name: CareCode + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + flyway: enabled: false baseline-on-migrate: true + baseline-version: 0 locations: classpath:db/migration - # 프로젝트 프로파일 직접 선택으로 변경 - profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} \ No newline at end of file + # Spring Batch: 기동 시 잡 자동 실행 비활성화 + batch: + job: + enabled: false + jdbc: + initialize-schema: never + + # Redis (Boot 3 정식 키는 spring.data.redis.*) + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + timeout: 2s + + datasource: + url: ${DB_URL:jdbc:mariadb://localhost:3306/carecode} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD} + driver-class-name: org.mariadb.jdbc.Driver + hikari: + maximum-pool-size: ${DB_POOL_MAX_SIZE:10} + minimum-idle: ${DB_POOL_MIN_IDLE:5} + idle-timeout: 30000 + connection-timeout: 20000 + max-lifetime: 1800000 + + jpa: + database-platform: org.hibernate.dialect.MariaDBDialect + open-in-view: false + properties: + hibernate: + format_sql: true + jdbc: + batch_size: 50 + order_inserts: true + order_updates: true + + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET:YOUR_GOOGLE_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/google" + scope: profile,email + # Kakao: 실제 로그인 흐름은 REST(POST /auth/kakao/login + KakaoUtil)다. + # 아래 registration 은 클라이언트 자격증명 제공용이며 oauth2Login() 은 활성화하지 않는다. + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + authorization-grant-type: authorization_code + scope: profile_nickname,profile_image + client-name: Kakao + client-authentication-method: client_secret_post + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 20MB + +server: + port: ${SERVER_PORT:8082} + servlet: + context-path: / + # 리버스 프록시 뒤에서 클라이언트 IP/프로토콜을 복원한다 (rate limit 정확도에 필요). + forward-headers-strategy: framework + +app: + api: + base-url: /api/v1 + version: v1 + title: 맘편한 API + description: 육아 지원 플랫폼 맘편한의 REST API + security: + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000} + rate-limit: + # 신뢰할 수 있는 프록시 뒤에 있을 때만 X-Forwarded-For 를 사용한다. + # 프록시가 없는데 true 로 두면 헤더 위조로 rate limit 을 우회할 수 있다. + trust-forwarded-headers: ${RATE_LIMIT_TRUST_XFF:false} + storage: + # 다중 인스턴스로 확장할 때는 FileStorageService 의 S3 구현체로 교체한다. + local: + root: ${STORAGE_ROOT:./uploads} + public-base-url: ${STORAGE_PUBLIC_BASE_URL:/files} + max-file-size-bytes: ${STORAGE_MAX_FILE_SIZE:10485760} + notification: + fcm: + # 서비스 계정 JSON 경로 (예: file:/opt/carecode/fcm-service-account.json). + # 비워두면 푸시 발송이 비활성화된다. + credentials-location: ${FCM_CREDENTIALS_LOCATION:} + sms: + enabled: ${SMS_ENABLED:false} + chatbot: + claude: + # 비워두면 챗봇이 규칙 기반 응답으로 동작한다 (로컬/CI 에서 키 없이 기동 가능). + api-key: ${ANTHROPIC_API_KEY:} + model: ${CHATBOT_MODEL:claude-opus-5} + max-tokens: ${CHATBOT_MAX_TOKENS:1024} + community: + # 이 횟수 이상 신고되면 관리자 확인 전까지 자동으로 숨긴다. + auto-hide-report-threshold: ${COMMUNITY_AUTO_HIDE_THRESHOLD:5} + scheduler: + vaccination: + cron: ${VACCINATION_REMINDER_CRON:0 0 9 * * *} + reminder-days-before: ${VACCINATION_REMINDER_DAYS:7} + booking: + cron: ${BOOKING_REMINDER_CRON:0 0 18 * * *} + cleanup: + cron: ${CLEANUP_CRON:0 0 4 * * *} + public-data: + # 시설 정보는 변동이 잦지 않아 주 1회, 정책은 신청 기간이 있어 매일 확인한다. + facility-cron: ${PUBLIC_DATA_FACILITY_CRON:0 0 3 * * MON} + kindergarten-cron: ${PUBLIC_DATA_KINDERGARTEN_CRON:0 0 4 * * MON} + benefit-cron: ${PUBLIC_DATA_BENEFIT_CRON:0 30 3 * * *} + hospital-cron: ${PUBLIC_DATA_HOSPITAL_CRON:0 0 3 * * TUE} + +jwt: + secret: ${JWT_SECRET} + issuer: ${JWT_ISSUER:carecode} + access-token: + expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} # 1시간 + refresh-token: + expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:2592000000} # 30일 + # none: JWT 서명만 검증. redis: 리프레시 토큰을 Redis에 등록·회전·로그아웃 시 일괄 폐기. + store: ${JWT_REFRESH_TOKEN_STORE:none} + +springdoc: + swagger-ui: + path: /swagger-ui.html + operationsSorter: method + tagsSorter: alpha + api-docs: + path: /api-docs + packages-to-scan: com.carecode + default-consumes-media-type: application/json + default-produces-media-type: application/json + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + base-path: /actuator + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + prometheus: + metrics: + export: + enabled: true + metrics: + tags: + application: ${spring.application.name} + health: + livenessState: + enabled: true + readinessState: + enabled: true + +kakao: + redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:3000/auth/kakao/callback} + +public: + data: + # 레거시 키 (서울 열린데이터광장 전용 CareFacilityApiService 가 참조) + api: + key: ${PUBLIC_DATA_API_KEY} + base-url: ${PUBLIC_DATA_API_BASE_URL:http://openapi.seoul.go.kr:8088} + + # 서울 열린데이터광장 — 경로 파라미터 방식 + seoul: + api-key: ${SEOUL_OPEN_DATA_KEY:${PUBLIC_DATA_API_KEY:}} + base-url: ${SEOUL_OPEN_DATA_BASE_URL:http://openapi.seoul.go.kr:8088} + + # 공공데이터포털(data.go.kr) — serviceKey 쿼리 파라미터 방식. + # 비워두면 해당 공급자를 쓰는 동기화가 건너뛰어진다(기동에는 영향 없음). + datagokr: + service-key: ${DATA_GO_KR_SERVICE_KEY:} + base-url: ${DATA_GO_KR_BASE_URL:https://apis.data.go.kr} + + # 심평원 병원정보서비스 + hospital: + # 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". + # 코드가 개정될 수 있어 설정으로 뺀다. + pediatric-subject-code: ${HOSPITAL_PEDIATRIC_SUBJECT_CODE:10} + subject-name: ${HOSPITAL_SUBJECT_NAME:소아청소년과} + + # 데이터셋 경로. 공공데이터 오퍼레이션은 개편되므로 재배포 없이 바꿀 수 있게 뺀다. + # 절대 URL 을 넣으면 base-url 대신 그대로 호출한다(표준데이터는 호스트가 다르다). + resource: + childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:B551014/CCEF/childcare} + kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api} + benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:1741000/publicServiceInformations/publicServiceInformation} + hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList} + + sync: + # 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다. + max-pages: ${PUBLIC_DATA_SYNC_MAX_PAGES:200} diff --git a/src/main/resources/db/index.sql b/src/main/resources/db/index.sql deleted file mode 100644 index cbf7bc73..00000000 --- a/src/main/resources/db/index.sql +++ /dev/null @@ -1,603 +0,0 @@ --- ================================================================================ --- CareCode Database Schema --- MariaDB/MySQL DDL --- Generated: 2025-11-02 --- ================================================================================ - --- Drop existing tables (in reverse order of dependencies) -DROP TABLE IF EXISTS TBL_EMAIL_VERIFICATION_TOKEN; -DROP TABLE IF EXISTS care_facility_bookings; -DROP TABLE IF EXISTS TBL_CHAT_MESSAGES; -DROP TABLE IF EXISTS TBL_CHAT_SESSIONS; -DROP TABLE IF EXISTS TBL_NOTIFICATION_SETTINGS; -DROP TABLE IF EXISTS TBL_NOTIFICATION_TEMPLATES; -DROP TABLE IF EXISTS notification_preferences; -DROP TABLE IF EXISTS TBL_NOTIFICATION; -DROP TABLE IF EXISTS TBL_POLICY_DOCUMENTS; -DROP TABLE IF EXISTS TBL_POLICIES; -DROP TABLE IF EXISTS TBL_POLICY_CATEGORIES; -DROP TABLE IF EXISTS TBL_HEALTH_RECORD_ATTACHMENTS; -DROP TABLE IF EXISTS TBL_HEALTH_RECORD; -DROP TABLE IF EXISTS TBL_HOSPITAL_REVIEW; -DROP TABLE IF EXISTS TBL_HOSPITAL_LIKE; -DROP TABLE IF EXISTS TBL_HOSPITAL; -DROP TABLE IF EXISTS TBL_BOOKMARK; -DROP TABLE IF EXISTS TBL_POST_LIKE; -DROP TABLE IF EXISTS TBL_POST_TAGS; -DROP TABLE IF EXISTS TBL_TAG; -DROP TABLE IF EXISTS TBL_COMMENT; -DROP TABLE IF EXISTS TBL_POST; -DROP TABLE IF EXISTS TBL_REVIEWS; -DROP TABLE IF EXISTS TBL_CARE_FACILITIES; -DROP TABLE IF EXISTS TBL_CHILD; -DROP TABLE IF EXISTS TBL_USER; - --- ================================================================================ --- 1. User Domain Tables --- ================================================================================ - --- User Table -CREATE TABLE TBL_USER ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID VARCHAR(100) NOT NULL COMMENT '자동 생성된 사용자 ID', - EMAIL VARCHAR(255) NOT NULL UNIQUE COMMENT '이메일 (고유)', - PASSWORD VARCHAR(255) COMMENT '비밀번호 (OAuth 사용자는 NULL)', - NAME VARCHAR(100) NOT NULL COMMENT '사용자명', - PHONE_NUMBER VARCHAR(20) COMMENT '전화번호', - BIRTH_DATE DATE COMMENT '생년월일', - GENDER VARCHAR(20) COMMENT '성별 (MALE, FEMALE, OTHER)', - ADDRESS VARCHAR(500) COMMENT '주소', - LATITUDE DOUBLE COMMENT '위도', - LONGITUDE DOUBLE COMMENT '경도', - PROFILE_IMAGE_URL VARCHAR(500) COMMENT '프로필 이미지 URL', - ROLE VARCHAR(50) NOT NULL COMMENT '역할 (PARENT, CAREGIVER, ADMIN, GUEST)', - PROVIDER VARCHAR(50) COMMENT 'OAuth 제공자 (kakao, google, naver)', - PROVIDER_ID VARCHAR(255) COMMENT 'OAuth 제공자의 사용자 ID', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 상태', - EMAIL_VERIFIED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '이메일 인증 여부', - REGISTRATION_COMPLETED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '가입 완료 여부', - DELETED_AT DATETIME COMMENT '소프트 삭제 시간', - LAST_LOGIN_AT DATETIME COMMENT '마지막 로그인 시간', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간', - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시간', - - INDEX idx_email (EMAIL), - INDEX idx_provider (PROVIDER, PROVIDER_ID), - INDEX idx_role (ROLE), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 테이블'; - --- Child Table -CREATE TABLE TBL_CHILD ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL COMMENT '부모 사용자 ID', - NAME VARCHAR(100) NOT NULL COMMENT '자녀명', - AGE INT COMMENT '나이', - GENDER VARCHAR(20) COMMENT '성별', - BIRTH_DATE DATE COMMENT '생년월일', - SPECIAL_NEEDS TEXT COMMENT '특별한 요구사항', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_user_id (USER_ID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='자녀 테이블'; - --- Notification Settings Table -CREATE TABLE TBL_NOTIFICATION_SETTINGS ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL UNIQUE COMMENT '사용자 ID (One-to-One)', - POLICY_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '정책 알림', - FACILITY_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '시설 알림', - COMMUNITY_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '커뮤니티 알림', - CHATBOT_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '챗봇 알림', - EMAIL_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '이메일 알림', - PUSH_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '푸시 알림', - SMS_NOTIFICATION BOOLEAN DEFAULT FALSE COMMENT 'SMS 알림', - QUIET_HOURS_START TIME COMMENT '조용한 시간 시작', - QUIET_HOURS_END TIME COMMENT '조용한 시간 종료', - QUIET_HOURS_ENABLED BOOLEAN DEFAULT FALSE COMMENT '조용한 시간 활성화', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 알림 설정'; - --- Email Verification Token Table -CREATE TABLE TBL_EMAIL_VERIFICATION_TOKEN ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL, - TOKEN VARCHAR(255) NOT NULL UNIQUE COMMENT '인증 토큰', - EXPIRY_DATE DATETIME NOT NULL COMMENT '만료 날짜', - USED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '사용 여부', - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_token (TOKEN), - INDEX idx_user_id (USER_ID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='이메일 인증 토큰'; - --- ================================================================================ --- 2. Care Facility Domain Tables --- ================================================================================ - --- Care Facility Table -CREATE TABLE TBL_CARE_FACILITIES ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - FACILITY_CODE VARCHAR(100) NOT NULL UNIQUE COMMENT '시설 코드', - NAME VARCHAR(200) NOT NULL COMMENT '시설명', - FACILITY_TYPE VARCHAR(50) COMMENT '시설 유형 (DAYCARE, KINDERGARTEN, NURSERY, PLAYGROUP, FAMILY_CARE, OTHER)', - CITY VARCHAR(100) COMMENT '도시', - DISTRICT VARCHAR(100) COMMENT '지역', - ADDRESS VARCHAR(500) COMMENT '주소', - LATITUDE DOUBLE COMMENT '위도', - LONGITUDE DOUBLE COMMENT '경도', - PHONE VARCHAR(20) COMMENT '전화번호', - EMAIL VARCHAR(255) COMMENT '이메일', - WEBSITE VARCHAR(500) COMMENT '웹사이트', - CAPACITY INT COMMENT '수용인원', - CURRENT_ENROLLMENT INT COMMENT '현재 입소자 수', - AVAILABLE_SPOTS INT COMMENT '가능한 자리 수', - AGE_RANGE_MIN INT COMMENT '최소 나이', - AGE_RANGE_MAX INT COMMENT '최대 나이', - AGE_RANGE VARCHAR(100) COMMENT '나이 범위', - OPERATING_HOURS VARCHAR(200) COMMENT '운영 시간', - TUITION_FEE INT COMMENT '학비', - ADDITIONAL_FEES TEXT COMMENT '추가 수수료', - FACILITIES TEXT COMMENT '시설 내 편의시설', - CURRICULUM TEXT COMMENT '커리큘럼', - TEACHER_COUNT INT COMMENT '교사 수', - STUDENT_TEACHER_RATIO VARCHAR(50) COMMENT '학생 대 교사 비율', - ACCREDITATION VARCHAR(200) COMMENT '인증 정보', - RATING DOUBLE COMMENT '평점', - REVIEW_COUNT INT DEFAULT 0 COMMENT '리뷰 수', - VIEW_COUNT INT DEFAULT 0 COMMENT '조회수', - DESCRIPTION TEXT COMMENT '설명', - IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 상태', - IS_PUBLIC BOOLEAN DEFAULT FALSE COMMENT '공립/사립 여부', - SUBSIDY_AVAILABLE BOOLEAN DEFAULT FALSE COMMENT '보조금 지원 여부', - CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - INDEX idx_facility_code (FACILITY_CODE), - INDEX idx_facility_type (FACILITY_TYPE), - INDEX idx_city (CITY), - INDEX idx_district (DISTRICT), - INDEX idx_rating (RATING), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설'; - --- Care Facility Booking Table -CREATE TABLE care_facility_bookings ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - FACILITY_ID BIGINT NOT NULL, - USER_ID VARCHAR(100) NOT NULL COMMENT '사용자 ID', - CHILD_NAME VARCHAR(100) NOT NULL COMMENT '자녀명', - CHILD_AGE INT COMMENT '자녀 나이', - PARENT_NAME VARCHAR(100) NOT NULL COMMENT '부모명', - PARENT_PHONE VARCHAR(20) NOT NULL COMMENT '부모 전화번호', - BOOKING_TYPE VARCHAR(50) NOT NULL COMMENT '예약 유형 (VISIT, REGULAR, TEMPORARY)', - STATUS VARCHAR(50) NOT NULL COMMENT '예약 상태 (PENDING, CONFIRMED, CANCELLED, COMPLETED)', - START_TIME DATETIME NOT NULL COMMENT '시작 시간', - END_TIME DATETIME NOT NULL COMMENT '종료 시간', - SPECIAL_REQUIREMENTS TEXT COMMENT '특별 요청사항', - NOTES TEXT COMMENT '메모', - CANCELLATION_REASON VARCHAR(500) COMMENT '취소 사유', - CANCELLED_AT DATETIME COMMENT '취소 시간', - ACTUAL_START_TIME DATETIME COMMENT '실제 시작 시간', - ACTUAL_END_TIME DATETIME COMMENT '실제 종료 시간', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (FACILITY_ID) REFERENCES TBL_CARE_FACILITIES(ID) ON DELETE CASCADE, - INDEX idx_facility_id (FACILITY_ID), - INDEX idx_user_id (USER_ID), - INDEX idx_status (STATUS), - INDEX idx_start_time (START_TIME) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설 예약'; - --- Review Table -CREATE TABLE TBL_REVIEWS ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - FACILITY_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - RATING INT NOT NULL COMMENT '평점 (1-5)', - CONTENT TEXT COMMENT '리뷰 내용', - IS_VERIFIED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '검증 여부', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (FACILITY_ID) REFERENCES TBL_CARE_FACILITIES(ID) ON DELETE CASCADE, - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_facility_id (FACILITY_ID), - INDEX idx_user_id (USER_ID), - INDEX idx_rating (RATING), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설 리뷰'; - --- ================================================================================ --- 3. Community Domain Tables --- ================================================================================ - --- Post Table -CREATE TABLE TBL_POST ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - TITLE VARCHAR(200) NOT NULL COMMENT '게시글 제목', - CONTENT TEXT COMMENT '게시글 내용', - AUTHOR_ID BIGINT NOT NULL, - AUTHOR_NAME VARCHAR(100) COMMENT '작성자명', - CATEGORY VARCHAR(50) NOT NULL COMMENT '카테고리 (GENERAL, QUESTION, SHARE, REVIEW, NEWS, EVENT, NOTICE)', - IS_ANONYMOUS BOOLEAN DEFAULT FALSE COMMENT '익명 여부', - VIEW_COUNT INT DEFAULT 0 COMMENT '조회수', - LIKE_COUNT INT DEFAULT 0 COMMENT '좋아요 수', - COMMENT_COUNT INT DEFAULT 0 COMMENT '댓글 수', - STATUS VARCHAR(50) DEFAULT 'PUBLISHED' COMMENT '상태 (DRAFT, PUBLISHED, HIDDEN, DELETED)', - IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (AUTHOR_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_author_id (AUTHOR_ID), - INDEX idx_category (CATEGORY), - INDEX idx_status (STATUS), - INDEX idx_created_at (CREATED_AT), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글'; - --- Comment Table -CREATE TABLE TBL_COMMENT ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - POST_ID BIGINT NOT NULL, - CONTENT TEXT NOT NULL COMMENT '댓글 내용', - AUTHOR_ID BIGINT NOT NULL, - AUTHOR_NAME VARCHAR(100) COMMENT '작성자명', - LIKE_COUNT INT DEFAULT 0 COMMENT '좋아요 수', - STATUS VARCHAR(50) DEFAULT 'PUBLISHED' COMMENT '상태', - IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 여부', - PARENT_COMMENT_ID BIGINT COMMENT '부모 댓글 ID (답글)', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, - FOREIGN KEY (AUTHOR_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - FOREIGN KEY (PARENT_COMMENT_ID) REFERENCES TBL_COMMENT(ID) ON DELETE CASCADE, - INDEX idx_post_id (POST_ID), - INDEX idx_author_id (AUTHOR_ID), - INDEX idx_parent_comment_id (PARENT_COMMENT_ID), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='댓글'; - --- Tag Table -CREATE TABLE TBL_TAG ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - NAME VARCHAR(50) NOT NULL UNIQUE COMMENT '태그명', - DESCRIPTION VARCHAR(200) COMMENT '태그 설명', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - INDEX idx_name (NAME), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='태그'; - --- Post-Tag Junction Table (Many-to-Many) -CREATE TABLE TBL_POST_TAGS ( - POST_ID BIGINT NOT NULL, - TAG_ID BIGINT NOT NULL, - - PRIMARY KEY (POST_ID, TAG_ID), - FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, - FOREIGN KEY (TAG_ID) REFERENCES TBL_TAG(ID) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글-태그 연결'; - --- Post Like Table -CREATE TABLE TBL_POST_LIKE ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - POST_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - UNIQUE KEY uk_post_user (POST_ID, USER_ID), - FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_post_id (POST_ID), - INDEX idx_user_id (USER_ID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 좋아요'; - --- Bookmark Table -CREATE TABLE TBL_BOOKMARK ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - POST_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - UNIQUE KEY uk_post_user (POST_ID, USER_ID), - FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_post_id (POST_ID), - INDEX idx_user_id (USER_ID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='북마크'; - --- ================================================================================ --- 4. Health Domain Tables --- ================================================================================ - --- Hospital Table -CREATE TABLE TBL_HOSPITAL ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - NAME VARCHAR(200) NOT NULL COMMENT '병원명', - TYPE VARCHAR(100) COMMENT '병원 유형 (소아과, 산부인과 등)', - ADDRESS VARCHAR(500) COMMENT '주소', - LATITUDE DOUBLE COMMENT '위도', - LONGITUDE DOUBLE COMMENT '경도', - PHONE VARCHAR(20) COMMENT '전화번호', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - INDEX idx_name (NAME), - INDEX idx_type (TYPE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='병원'; - --- Hospital Like Table -CREATE TABLE TBL_HOSPITAL_LIKE ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - HOSPITAL_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, - - UNIQUE KEY uk_hospital_user (HOSPITAL_ID, USER_ID), - FOREIGN KEY (HOSPITAL_ID) REFERENCES TBL_HOSPITAL(ID) ON DELETE CASCADE, - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_hospital_id (HOSPITAL_ID), - INDEX idx_user_id (USER_ID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='병원 좋아요'; - --- Hospital Review Table -CREATE TABLE TBL_HOSPITAL_REVIEW ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - HOSPITAL_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - RATING INT NOT NULL COMMENT '평점', - CONTENT TEXT COMMENT '리뷰 내용', - CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (HOSPITAL_ID) REFERENCES TBL_HOSPITAL(ID) ON DELETE CASCADE, - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_hospital_id (HOSPITAL_ID), - INDEX idx_user_id (USER_ID), - INDEX idx_rating (RATING) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='병원 리뷰'; - --- Health Record Table -CREATE TABLE TBL_HEALTH_RECORD ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL, - CHILD_ID BIGINT COMMENT '자녀 ID', - RECORD_TYPE VARCHAR(50) NOT NULL COMMENT '기록 유형 (VACCINATION, CHECKUP, ILLNESS, GROWTH, DENTAL, EYE, EMERGENCY, OTHER)', - TITLE VARCHAR(200) NOT NULL COMMENT '제목', - DESCRIPTION TEXT COMMENT '설명', - RECORD_DATE DATE NOT NULL COMMENT '기록 날짜', - NEXT_DATE DATE COMMENT '다음 예정일', - LOCATION VARCHAR(200) COMMENT '위치', - DOCTOR_NAME VARCHAR(100) COMMENT '의사명', - HOSPITAL_NAME VARCHAR(200) COMMENT '병원명', - HEIGHT DOUBLE COMMENT '키 (cm)', - WEIGHT DOUBLE COMMENT '몸무게 (kg)', - TEMPERATURE DOUBLE COMMENT '체온 (°C)', - BLOOD_PRESSURE VARCHAR(20) COMMENT '혈압 (120/80)', - PULSE_RATE INT COMMENT '맥박 (회/분)', - VACCINE_NAME VARCHAR(100) COMMENT '예방접종명', - VACCINE_BATCH VARCHAR(100) COMMENT '백신 배치번호', - SYMPTOMS TEXT COMMENT '증상', - DIAGNOSIS TEXT COMMENT '진단', - TREATMENT TEXT COMMENT '치료', - MEDICATION TEXT COMMENT '처방약', - NOTES TEXT COMMENT '기타 메모', - STATUS VARCHAR(50) DEFAULT 'COMPLETED' COMMENT '상태 (SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, MISSED)', - IS_COMPLETED BOOLEAN DEFAULT FALSE COMMENT '완료 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - FOREIGN KEY (CHILD_ID) REFERENCES TBL_CHILD(ID) ON DELETE SET NULL, - INDEX idx_user_id (USER_ID), - INDEX idx_child_id (CHILD_ID), - INDEX idx_record_type (RECORD_TYPE), - INDEX idx_record_date (RECORD_DATE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='건강기록'; - --- Health Record Attachment Table -CREATE TABLE TBL_HEALTH_RECORD_ATTACHMENTS ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - HEALTH_RECORD_ID BIGINT NOT NULL, - FILE_URL VARCHAR(500) NOT NULL COMMENT '파일 URL', - FILE_NAME VARCHAR(200) NOT NULL COMMENT '원본 파일명', - FILE_TYPE VARCHAR(50) COMMENT 'MIME 타입', - FILE_SIZE BIGINT COMMENT '파일 크기 (바이트)', - DESCRIPTION VARCHAR(500) COMMENT '첨부파일 설명', - DISPLAY_ORDER INT NOT NULL DEFAULT 0 COMMENT '표시 순서', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (HEALTH_RECORD_ID) REFERENCES TBL_HEALTH_RECORD(ID) ON DELETE CASCADE, - INDEX idx_health_record_id (HEALTH_RECORD_ID), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='건강기록 첨부파일'; - --- ================================================================================ --- 5. Policy Domain Tables --- ================================================================================ - --- Policy Category Table -CREATE TABLE TBL_POLICY_CATEGORIES ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - NAME VARCHAR(100) NOT NULL UNIQUE COMMENT '카테고리명', - DESCRIPTION VARCHAR(500) COMMENT '카테고리 설명', - DISPLAY_ORDER INT NOT NULL DEFAULT 0 COMMENT '표시 순서', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - INDEX idx_name (NAME), - INDEX idx_is_active (IS_ACTIVE), - INDEX idx_display_order (DISPLAY_ORDER) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책 카테고리'; - --- Policy Table -CREATE TABLE TBL_POLICIES ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - POLICY_CODE VARCHAR(100) NOT NULL UNIQUE COMMENT '정책 코드', - TITLE VARCHAR(200) NOT NULL COMMENT '정책 제목', - DESCRIPTION TEXT COMMENT '정책 설명', - POLICY_TYPE VARCHAR(100) COMMENT '정책 유형', - TARGET_AGE_MIN INT COMMENT '대상 최소 나이', - TARGET_AGE_MAX INT COMMENT '대상 최대 나이', - TARGET_REGION VARCHAR(100) COMMENT '대상 지역', - BENEFIT_AMOUNT INT COMMENT '지원금액', - BENEFIT_TYPE VARCHAR(100) COMMENT '지원 유형', - APPLICATION_START_DATE DATE COMMENT '신청 시작일', - APPLICATION_END_DATE DATE COMMENT '신청 종료일', - POLICY_START_DATE DATE COMMENT '정책 시작일', - POLICY_END_DATE DATE COMMENT '정책 종료일', - APPLICATION_URL VARCHAR(500) COMMENT '신청 URL', - CONTACT_INFO VARCHAR(200) COMMENT '연락처', - REQUIRED_DOCUMENTS TEXT COMMENT '필수 서류', - IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 여부', - PRIORITY INT DEFAULT 0 COMMENT '우선순위', - CATEGORY_ID BIGINT COMMENT '카테고리 ID', - CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (CATEGORY_ID) REFERENCES TBL_POLICY_CATEGORIES(ID) ON DELETE SET NULL, - INDEX idx_policy_code (POLICY_CODE), - INDEX idx_category_id (CATEGORY_ID), - INDEX idx_is_active (IS_ACTIVE), - INDEX idx_target_region (TARGET_REGION) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책'; - --- Policy Document Table -CREATE TABLE TBL_POLICY_DOCUMENTS ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - POLICY_ID BIGINT NOT NULL, - DOCUMENT_URL VARCHAR(500) NOT NULL COMMENT '문서 URL', - DOCUMENT_TYPE VARCHAR(50) COMMENT '문서 유형', - FILE_NAME VARCHAR(200) NOT NULL COMMENT '파일명', - FILE_SIZE BIGINT COMMENT '파일 크기', - DESCRIPTION VARCHAR(500) COMMENT '문서 설명', - DISPLAY_ORDER INT NOT NULL DEFAULT 0 COMMENT '표시 순서', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (POLICY_ID) REFERENCES TBL_POLICIES(ID) ON DELETE CASCADE, - INDEX idx_policy_id (POLICY_ID), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책 문서'; - --- ================================================================================ --- 6. Notification Domain Tables --- ================================================================================ - --- Notification Table -CREATE TABLE TBL_NOTIFICATION ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL, - NOTIFICATION_TYPE VARCHAR(50) NOT NULL COMMENT '알림 유형 (POLICY, HEALTH, COMMUNITY, SYSTEM)', - TITLE VARCHAR(200) NOT NULL COMMENT '알림 제목', - MESSAGE TEXT NOT NULL COMMENT '알림 메시지', - IS_READ BOOLEAN NOT NULL DEFAULT FALSE COMMENT '읽음 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_user_id (USER_ID), - INDEX idx_notification_type (NOTIFICATION_TYPE), - INDEX idx_is_read (IS_READ), - INDEX idx_created_at (CREATED_AT) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림'; - --- Notification Preference Table -CREATE TABLE notification_preferences ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL, - NOTIFICATION_TYPE VARCHAR(50) NOT NULL COMMENT '알림 유형', - EMAIL_ENABLED BOOLEAN NOT NULL DEFAULT TRUE COMMENT '이메일 활성화', - PUSH_ENABLED BOOLEAN NOT NULL DEFAULT TRUE COMMENT '푸시 활성화', - SMS_ENABLED BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'SMS 활성화', - IN_APP_ENABLED BOOLEAN NOT NULL DEFAULT TRUE COMMENT '인앱 활성화', - EMAIL_ADDRESS VARCHAR(255) COMMENT '이메일 주소', - PHONE_NUMBER VARCHAR(20) COMMENT '전화번호', - DEVICE_TOKEN VARCHAR(500) COMMENT '디바이스 토큰', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_user_id (USER_ID), - INDEX idx_notification_type (NOTIFICATION_TYPE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 설정'; - --- Notification Template Table -CREATE TABLE TBL_NOTIFICATION_TEMPLATES ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - TEMPLATE_CODE VARCHAR(50) NOT NULL UNIQUE COMMENT '템플릿 코드', - TITLE VARCHAR(200) NOT NULL COMMENT '알림 제목 템플릿', - CONTENT TEXT NOT NULL COMMENT '알림 내용 템플릿', - TEMPLATE_TYPE VARCHAR(50) COMMENT '템플릿 타입 (EMAIL, PUSH, SMS, SYSTEM)', - DESCRIPTION VARCHAR(500) COMMENT '템플릿 설명', - IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - INDEX idx_template_code (TEMPLATE_CODE), - INDEX idx_template_type (TEMPLATE_TYPE), - INDEX idx_is_active (IS_ACTIVE) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 템플릿'; - --- ================================================================================ --- 7. Chatbot Domain Tables --- ================================================================================ - --- Chat Session Table -CREATE TABLE TBL_CHAT_SESSIONS ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - SESSION_ID VARCHAR(255) NOT NULL UNIQUE COMMENT '세션 ID', - USER_ID BIGINT NOT NULL, - TITLE VARCHAR(200) NOT NULL COMMENT '세션 제목', - DESCRIPTION VARCHAR(500) COMMENT '세션 설명', - STATUS VARCHAR(50) NOT NULL COMMENT '세션 상태 (ACTIVE, PAUSED, ENDED)', - MESSAGE_COUNT INT DEFAULT 0 COMMENT '메시지 수', - LAST_ACTIVITY_AT DATETIME COMMENT '마지막 활동 시간', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - ENDED_AT DATETIME COMMENT '종료 시간', - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_session_id (SESSION_ID), - INDEX idx_user_id (USER_ID), - INDEX idx_status (STATUS) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채팅 세션'; - --- Chat Message Table -CREATE TABLE TBL_CHAT_MESSAGES ( - ID BIGINT AUTO_INCREMENT PRIMARY KEY, - USER_ID BIGINT NOT NULL, - MESSAGE VARCHAR(1000) NOT NULL COMMENT '사용자 메시지', - RESPONSE VARCHAR(2000) NOT NULL COMMENT '챗봇 응답', - MESSAGE_TYPE VARCHAR(50) NOT NULL COMMENT '메시지 타입 (USER, BOT)', - INTENT_TYPE VARCHAR(100) NOT NULL COMMENT '의도 타입 (GREETING, QUESTION, COMPLAINT, THANKS, GOODBYE, etc.)', - CONFIDENCE DOUBLE COMMENT '신뢰도', - SESSION_ID VARCHAR(255) COMMENT '세션 ID', - IS_HELPFUL BOOLEAN COMMENT '도움이 되었는지', - CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, - INDEX idx_user_id (USER_ID), - INDEX idx_session_id (SESSION_ID), - INDEX idx_created_at (CREATED_AT) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채팅 메시지'; - --- ================================================================================ --- End of CareCode Database Schema --- ================================================================================ diff --git a/src/main/resources/db/migration/V1__baseline.sql b/src/main/resources/db/migration/V1__baseline.sql index 2059d24a..a34fdb59 100644 --- a/src/main/resources/db/migration/V1__baseline.sql +++ b/src/main/resources/db/migration/V1__baseline.sql @@ -1,4 +1,578 @@ --- Flyway baseline migration for CareCode. --- Production uses spring.jpa.hibernate.ddl-auto=validate; add forward migrations here as the schema evolves. +-- ================================================================================ +-- CareCode baseline schema (V1) +-- MariaDB/MySQL DDL +-- +-- 주의: 이 파일은 Flyway 가 신규 환경에 적용하는 최초 스키마다. +-- 기존 운영 DB 는 flyway.baseline-on-migrate=true + baseline-version=0 으로 건너뛴다. +-- 이후 스키마 변경은 이 파일을 수정하지 말고 V2__*.sql 을 새로 추가한다. +-- ================================================================================ -SELECT 1; +-- ================================================================================ +-- 1. User Domain Tables +-- ================================================================================ + +-- User Table +CREATE TABLE TBL_USER ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID VARCHAR(100) NOT NULL COMMENT '자동 생성된 사용자 ID', + EMAIL VARCHAR(255) NOT NULL UNIQUE COMMENT '이메일 (고유)', + PASSWORD VARCHAR(255) COMMENT '비밀번호 (OAuth 사용자는 NULL)', + NAME VARCHAR(100) NOT NULL COMMENT '사용자명', + PHONE_NUMBER VARCHAR(20) COMMENT '전화번호', + BIRTH_DATE DATE COMMENT '생년월일', + GENDER VARCHAR(20) COMMENT '성별 (MALE, FEMALE, OTHER)', + ADDRESS VARCHAR(500) COMMENT '주소', + LATITUDE DOUBLE COMMENT '위도', + LONGITUDE DOUBLE COMMENT '경도', + PROFILE_IMAGE_URL VARCHAR(500) COMMENT '프로필 이미지 URL', + ROLE VARCHAR(50) NOT NULL COMMENT '역할 (PARENT, CAREGIVER, ADMIN, GUEST)', + PROVIDER VARCHAR(50) COMMENT 'OAuth 제공자 (kakao, google, naver)', + PROVIDER_ID VARCHAR(255) COMMENT 'OAuth 제공자의 사용자 ID', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 상태', + EMAIL_VERIFIED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '이메일 인증 여부', + REGISTRATION_COMPLETED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '가입 완료 여부', + DELETED_AT DATETIME COMMENT '소프트 삭제 시간', + LAST_LOGIN_AT DATETIME COMMENT '마지막 로그인 시간', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간', + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시간', + + INDEX idx_email (EMAIL), + INDEX idx_provider (PROVIDER, PROVIDER_ID), + INDEX idx_role (ROLE), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 테이블'; + +-- Child Table +CREATE TABLE TBL_CHILD ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL COMMENT '부모 사용자 ID', + NAME VARCHAR(100) NOT NULL COMMENT '자녀명', + AGE INT COMMENT '나이', + GENDER VARCHAR(20) COMMENT '성별', + BIRTH_DATE DATE COMMENT '생년월일', + SPECIAL_NEEDS TEXT COMMENT '특별한 요구사항', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_user_id (USER_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='자녀 테이블'; + +-- Notification Settings Table +CREATE TABLE TBL_NOTIFICATION_SETTINGS ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL UNIQUE COMMENT '사용자 ID (One-to-One)', + POLICY_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '정책 알림', + FACILITY_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '시설 알림', + COMMUNITY_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '커뮤니티 알림', + CHATBOT_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '챗봇 알림', + EMAIL_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '이메일 알림', + PUSH_NOTIFICATION BOOLEAN DEFAULT TRUE COMMENT '푸시 알림', + SMS_NOTIFICATION BOOLEAN DEFAULT FALSE COMMENT 'SMS 알림', + QUIET_HOURS_START TIME COMMENT '조용한 시간 시작', + QUIET_HOURS_END TIME COMMENT '조용한 시간 종료', + QUIET_HOURS_ENABLED BOOLEAN DEFAULT FALSE COMMENT '조용한 시간 활성화', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 알림 설정'; + +-- Email Verification Token Table +CREATE TABLE TBL_EMAIL_VERIFICATION_TOKEN ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL, + TOKEN VARCHAR(255) NOT NULL UNIQUE COMMENT '인증 토큰', + EXPIRY_DATE DATETIME NOT NULL COMMENT '만료 날짜', + USED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '사용 여부', + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_token (TOKEN), + INDEX idx_user_id (USER_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='이메일 인증 토큰'; + +-- ================================================================================ +-- 2. Care Facility Domain Tables +-- ================================================================================ + +-- Care Facility Table +CREATE TABLE TBL_CARE_FACILITIES ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + FACILITY_CODE VARCHAR(100) NOT NULL UNIQUE COMMENT '시설 코드', + NAME VARCHAR(200) NOT NULL COMMENT '시설명', + FACILITY_TYPE VARCHAR(50) COMMENT '시설 유형 (DAYCARE, KINDERGARTEN, NURSERY, PLAYGROUP, FAMILY_CARE, OTHER)', + CITY VARCHAR(100) COMMENT '도시', + DISTRICT VARCHAR(100) COMMENT '지역', + ADDRESS VARCHAR(500) COMMENT '주소', + LATITUDE DOUBLE COMMENT '위도', + LONGITUDE DOUBLE COMMENT '경도', + PHONE VARCHAR(20) COMMENT '전화번호', + EMAIL VARCHAR(255) COMMENT '이메일', + WEBSITE VARCHAR(500) COMMENT '웹사이트', + CAPACITY INT COMMENT '수용인원', + CURRENT_ENROLLMENT INT COMMENT '현재 입소자 수', + AVAILABLE_SPOTS INT COMMENT '가능한 자리 수', + AGE_RANGE_MIN INT COMMENT '최소 나이', + AGE_RANGE_MAX INT COMMENT '최대 나이', + AGE_RANGE VARCHAR(100) COMMENT '나이 범위', + OPERATING_HOURS VARCHAR(200) COMMENT '운영 시간', + TUITION_FEE INT COMMENT '학비', + ADDITIONAL_FEES TEXT COMMENT '추가 수수료', + FACILITIES TEXT COMMENT '시설 내 편의시설', + CURRICULUM TEXT COMMENT '커리큘럼', + TEACHER_COUNT INT COMMENT '교사 수', + STUDENT_TEACHER_RATIO VARCHAR(50) COMMENT '학생 대 교사 비율', + ACCREDITATION VARCHAR(200) COMMENT '인증 정보', + RATING DOUBLE COMMENT '평점', + REVIEW_COUNT INT DEFAULT 0 COMMENT '리뷰 수', + VIEW_COUNT INT DEFAULT 0 COMMENT '조회수', + DESCRIPTION TEXT COMMENT '설명', + IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + IS_PUBLIC BOOLEAN DEFAULT FALSE COMMENT '공립/사립 여부', + SUBSIDY_AVAILABLE BOOLEAN DEFAULT FALSE COMMENT '보조금 지원 여부', + CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_facility_code (FACILITY_CODE), + INDEX idx_facility_type (FACILITY_TYPE), + INDEX idx_city (CITY), + INDEX idx_district (DISTRICT), + INDEX idx_rating (RATING), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설'; + +-- Care Facility Booking Table +CREATE TABLE care_facility_bookings ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + FACILITY_ID BIGINT NOT NULL, + USER_ID VARCHAR(100) NOT NULL COMMENT '사용자 ID', + CHILD_NAME VARCHAR(100) NOT NULL COMMENT '자녀명', + CHILD_AGE INT COMMENT '자녀 나이', + PARENT_NAME VARCHAR(100) NOT NULL COMMENT '부모명', + PARENT_PHONE VARCHAR(20) NOT NULL COMMENT '부모 전화번호', + BOOKING_TYPE VARCHAR(50) NOT NULL COMMENT '예약 유형 (VISIT, REGULAR, TEMPORARY)', + STATUS VARCHAR(50) NOT NULL COMMENT '예약 상태 (PENDING, CONFIRMED, CANCELLED, COMPLETED)', + START_TIME DATETIME NOT NULL COMMENT '시작 시간', + END_TIME DATETIME NOT NULL COMMENT '종료 시간', + SPECIAL_REQUIREMENTS TEXT COMMENT '특별 요청사항', + NOTES TEXT COMMENT '메모', + CANCELLATION_REASON VARCHAR(500) COMMENT '취소 사유', + CANCELLED_AT DATETIME COMMENT '취소 시간', + ACTUAL_START_TIME DATETIME COMMENT '실제 시작 시간', + ACTUAL_END_TIME DATETIME COMMENT '실제 종료 시간', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (FACILITY_ID) REFERENCES TBL_CARE_FACILITIES(ID) ON DELETE CASCADE, + INDEX idx_facility_id (FACILITY_ID), + INDEX idx_user_id (USER_ID), + INDEX idx_status (STATUS), + INDEX idx_start_time (START_TIME) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설 예약'; + +-- Review Table +CREATE TABLE TBL_REVIEWS ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + FACILITY_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + RATING INT NOT NULL COMMENT '평점 (1-5)', + CONTENT TEXT COMMENT '리뷰 내용', + IS_VERIFIED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '검증 여부', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (FACILITY_ID) REFERENCES TBL_CARE_FACILITIES(ID) ON DELETE CASCADE, + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_facility_id (FACILITY_ID), + INDEX idx_user_id (USER_ID), + INDEX idx_rating (RATING), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설 리뷰'; + +-- ================================================================================ +-- 3. Community Domain Tables +-- ================================================================================ + +-- Post Table +CREATE TABLE TBL_POST ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + TITLE VARCHAR(200) NOT NULL COMMENT '게시글 제목', + CONTENT TEXT COMMENT '게시글 내용', + AUTHOR_ID BIGINT NOT NULL, + AUTHOR_NAME VARCHAR(100) COMMENT '작성자명', + CATEGORY VARCHAR(50) NOT NULL COMMENT '카테고리 (GENERAL, QUESTION, SHARE, REVIEW, NEWS, EVENT, NOTICE)', + IS_ANONYMOUS BOOLEAN DEFAULT FALSE COMMENT '익명 여부', + VIEW_COUNT INT DEFAULT 0 COMMENT '조회수', + LIKE_COUNT INT DEFAULT 0 COMMENT '좋아요 수', + COMMENT_COUNT INT DEFAULT 0 COMMENT '댓글 수', + STATUS VARCHAR(50) DEFAULT 'PUBLISHED' COMMENT '상태 (DRAFT, PUBLISHED, HIDDEN, DELETED)', + IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (AUTHOR_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_author_id (AUTHOR_ID), + INDEX idx_category (CATEGORY), + INDEX idx_status (STATUS), + INDEX idx_created_at (CREATED_AT), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글'; + +-- Comment Table +CREATE TABLE TBL_COMMENT ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POST_ID BIGINT NOT NULL, + CONTENT TEXT NOT NULL COMMENT '댓글 내용', + AUTHOR_ID BIGINT NOT NULL, + AUTHOR_NAME VARCHAR(100) COMMENT '작성자명', + LIKE_COUNT INT DEFAULT 0 COMMENT '좋아요 수', + STATUS VARCHAR(50) DEFAULT 'PUBLISHED' COMMENT '상태', + IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 여부', + PARENT_COMMENT_ID BIGINT COMMENT '부모 댓글 ID (답글)', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, + FOREIGN KEY (AUTHOR_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + FOREIGN KEY (PARENT_COMMENT_ID) REFERENCES TBL_COMMENT(ID) ON DELETE CASCADE, + INDEX idx_post_id (POST_ID), + INDEX idx_author_id (AUTHOR_ID), + INDEX idx_parent_comment_id (PARENT_COMMENT_ID), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='댓글'; + +-- Tag Table +CREATE TABLE TBL_TAG ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + NAME VARCHAR(50) NOT NULL UNIQUE COMMENT '태그명', + DESCRIPTION VARCHAR(200) COMMENT '태그 설명', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_name (NAME), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='태그'; + +-- Post-Tag Junction Table (Many-to-Many) +CREATE TABLE TBL_POST_TAGS ( + POST_ID BIGINT NOT NULL, + TAG_ID BIGINT NOT NULL, + + PRIMARY KEY (POST_ID, TAG_ID), + FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, + FOREIGN KEY (TAG_ID) REFERENCES TBL_TAG(ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글-태그 연결'; + +-- Post Like Table +CREATE TABLE TBL_POST_LIKE ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POST_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_post_user (POST_ID, USER_ID), + FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_post_id (POST_ID), + INDEX idx_user_id (USER_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 좋아요'; + +-- Bookmark Table +CREATE TABLE TBL_BOOKMARK ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POST_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_post_user (POST_ID, USER_ID), + FOREIGN KEY (POST_ID) REFERENCES TBL_POST(ID) ON DELETE CASCADE, + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_post_id (POST_ID), + INDEX idx_user_id (USER_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='북마크'; + +-- ================================================================================ +-- 4. Health Domain Tables +-- ================================================================================ + +-- Hospital Table +CREATE TABLE TBL_HOSPITAL ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + NAME VARCHAR(200) NOT NULL COMMENT '병원명', + TYPE VARCHAR(100) COMMENT '병원 유형 (소아과, 산부인과 등)', + ADDRESS VARCHAR(500) COMMENT '주소', + LATITUDE DOUBLE COMMENT '위도', + LONGITUDE DOUBLE COMMENT '경도', + PHONE VARCHAR(20) COMMENT '전화번호', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_name (NAME), + INDEX idx_type (TYPE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='병원'; + +-- Hospital Like Table +CREATE TABLE TBL_HOSPITAL_LIKE ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + HOSPITAL_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_hospital_user (HOSPITAL_ID, USER_ID), + FOREIGN KEY (HOSPITAL_ID) REFERENCES TBL_HOSPITAL(ID) ON DELETE CASCADE, + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_hospital_id (HOSPITAL_ID), + INDEX idx_user_id (USER_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='병원 좋아요'; + +-- Hospital Review Table +CREATE TABLE TBL_HOSPITAL_REVIEW ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + HOSPITAL_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + RATING INT NOT NULL COMMENT '평점', + CONTENT TEXT COMMENT '리뷰 내용', + CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (HOSPITAL_ID) REFERENCES TBL_HOSPITAL(ID) ON DELETE CASCADE, + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_hospital_id (HOSPITAL_ID), + INDEX idx_user_id (USER_ID), + INDEX idx_rating (RATING) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='병원 리뷰'; + +-- Health Record Table +CREATE TABLE TBL_HEALTH_RECORD ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL, + CHILD_ID BIGINT COMMENT '자녀 ID', + RECORD_TYPE VARCHAR(50) NOT NULL COMMENT '기록 유형 (VACCINATION, CHECKUP, ILLNESS, GROWTH, DENTAL, EYE, EMERGENCY, OTHER)', + TITLE VARCHAR(200) NOT NULL COMMENT '제목', + DESCRIPTION TEXT COMMENT '설명', + RECORD_DATE DATE NOT NULL COMMENT '기록 날짜', + NEXT_DATE DATE COMMENT '다음 예정일', + LOCATION VARCHAR(200) COMMENT '위치', + DOCTOR_NAME VARCHAR(100) COMMENT '의사명', + HOSPITAL_NAME VARCHAR(200) COMMENT '병원명', + HEIGHT DOUBLE COMMENT '키 (cm)', + WEIGHT DOUBLE COMMENT '몸무게 (kg)', + TEMPERATURE DOUBLE COMMENT '체온 (°C)', + BLOOD_PRESSURE VARCHAR(20) COMMENT '혈압 (120/80)', + PULSE_RATE INT COMMENT '맥박 (회/분)', + VACCINE_NAME VARCHAR(100) COMMENT '예방접종명', + VACCINE_BATCH VARCHAR(100) COMMENT '백신 배치번호', + SYMPTOMS TEXT COMMENT '증상', + DIAGNOSIS TEXT COMMENT '진단', + TREATMENT TEXT COMMENT '치료', + MEDICATION TEXT COMMENT '처방약', + NOTES TEXT COMMENT '기타 메모', + STATUS VARCHAR(50) DEFAULT 'COMPLETED' COMMENT '상태 (SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, MISSED)', + IS_COMPLETED BOOLEAN DEFAULT FALSE COMMENT '완료 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + FOREIGN KEY (CHILD_ID) REFERENCES TBL_CHILD(ID) ON DELETE SET NULL, + INDEX idx_user_id (USER_ID), + INDEX idx_child_id (CHILD_ID), + INDEX idx_record_type (RECORD_TYPE), + INDEX idx_record_date (RECORD_DATE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='건강기록'; + +-- Health Record Attachment Table +CREATE TABLE TBL_HEALTH_RECORD_ATTACHMENTS ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + HEALTH_RECORD_ID BIGINT NOT NULL, + FILE_URL VARCHAR(500) NOT NULL COMMENT '파일 URL', + FILE_NAME VARCHAR(200) NOT NULL COMMENT '원본 파일명', + FILE_TYPE VARCHAR(50) COMMENT 'MIME 타입', + FILE_SIZE BIGINT COMMENT '파일 크기 (바이트)', + DESCRIPTION VARCHAR(500) COMMENT '첨부파일 설명', + DISPLAY_ORDER INT NOT NULL DEFAULT 0 COMMENT '표시 순서', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (HEALTH_RECORD_ID) REFERENCES TBL_HEALTH_RECORD(ID) ON DELETE CASCADE, + INDEX idx_health_record_id (HEALTH_RECORD_ID), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='건강기록 첨부파일'; + +-- ================================================================================ +-- 5. Policy Domain Tables +-- ================================================================================ + +-- Policy Category Table +CREATE TABLE TBL_POLICY_CATEGORIES ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + NAME VARCHAR(100) NOT NULL UNIQUE COMMENT '카테고리명', + DESCRIPTION VARCHAR(500) COMMENT '카테고리 설명', + DISPLAY_ORDER INT NOT NULL DEFAULT 0 COMMENT '표시 순서', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_name (NAME), + INDEX idx_is_active (IS_ACTIVE), + INDEX idx_display_order (DISPLAY_ORDER) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책 카테고리'; + +-- Policy Table +CREATE TABLE TBL_POLICIES ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POLICY_CODE VARCHAR(100) NOT NULL UNIQUE COMMENT '정책 코드', + TITLE VARCHAR(200) NOT NULL COMMENT '정책 제목', + DESCRIPTION TEXT COMMENT '정책 설명', + POLICY_TYPE VARCHAR(100) COMMENT '정책 유형', + TARGET_AGE_MIN INT COMMENT '대상 최소 나이', + TARGET_AGE_MAX INT COMMENT '대상 최대 나이', + TARGET_REGION VARCHAR(100) COMMENT '대상 지역', + BENEFIT_AMOUNT INT COMMENT '지원금액', + BENEFIT_TYPE VARCHAR(100) COMMENT '지원 유형', + APPLICATION_START_DATE DATE COMMENT '신청 시작일', + APPLICATION_END_DATE DATE COMMENT '신청 종료일', + POLICY_START_DATE DATE COMMENT '정책 시작일', + POLICY_END_DATE DATE COMMENT '정책 종료일', + APPLICATION_URL VARCHAR(500) COMMENT '신청 URL', + CONTACT_INFO VARCHAR(200) COMMENT '연락처', + REQUIRED_DOCUMENTS TEXT COMMENT '필수 서류', + IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성 여부', + PRIORITY INT DEFAULT 0 COMMENT '우선순위', + CATEGORY_ID BIGINT COMMENT '카테고리 ID', + CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (CATEGORY_ID) REFERENCES TBL_POLICY_CATEGORIES(ID) ON DELETE SET NULL, + INDEX idx_policy_code (POLICY_CODE), + INDEX idx_category_id (CATEGORY_ID), + INDEX idx_is_active (IS_ACTIVE), + INDEX idx_target_region (TARGET_REGION) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책'; + +-- Policy Document Table +CREATE TABLE TBL_POLICY_DOCUMENTS ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POLICY_ID BIGINT NOT NULL, + DOCUMENT_URL VARCHAR(500) NOT NULL COMMENT '문서 URL', + DOCUMENT_TYPE VARCHAR(50) COMMENT '문서 유형', + FILE_NAME VARCHAR(200) NOT NULL COMMENT '파일명', + FILE_SIZE BIGINT COMMENT '파일 크기', + DESCRIPTION VARCHAR(500) COMMENT '문서 설명', + DISPLAY_ORDER INT NOT NULL DEFAULT 0 COMMENT '표시 순서', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (POLICY_ID) REFERENCES TBL_POLICIES(ID) ON DELETE CASCADE, + INDEX idx_policy_id (POLICY_ID), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책 문서'; + +-- ================================================================================ +-- 6. Notification Domain Tables +-- ================================================================================ + +-- Notification Table +CREATE TABLE TBL_NOTIFICATION ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL, + NOTIFICATION_TYPE VARCHAR(50) NOT NULL COMMENT '알림 유형 (POLICY, HEALTH, COMMUNITY, SYSTEM)', + TITLE VARCHAR(200) NOT NULL COMMENT '알림 제목', + MESSAGE TEXT NOT NULL COMMENT '알림 메시지', + IS_READ BOOLEAN NOT NULL DEFAULT FALSE COMMENT '읽음 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_user_id (USER_ID), + INDEX idx_notification_type (NOTIFICATION_TYPE), + INDEX idx_is_read (IS_READ), + INDEX idx_created_at (CREATED_AT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림'; + +-- Notification Preference Table +CREATE TABLE notification_preferences ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL, + NOTIFICATION_TYPE VARCHAR(50) NOT NULL COMMENT '알림 유형', + EMAIL_ENABLED BOOLEAN NOT NULL DEFAULT TRUE COMMENT '이메일 활성화', + PUSH_ENABLED BOOLEAN NOT NULL DEFAULT TRUE COMMENT '푸시 활성화', + SMS_ENABLED BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'SMS 활성화', + IN_APP_ENABLED BOOLEAN NOT NULL DEFAULT TRUE COMMENT '인앱 활성화', + EMAIL_ADDRESS VARCHAR(255) COMMENT '이메일 주소', + PHONE_NUMBER VARCHAR(20) COMMENT '전화번호', + DEVICE_TOKEN VARCHAR(500) COMMENT '디바이스 토큰', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_user_id (USER_ID), + INDEX idx_notification_type (NOTIFICATION_TYPE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 설정'; + +-- Notification Template Table +CREATE TABLE TBL_NOTIFICATION_TEMPLATES ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + TEMPLATE_CODE VARCHAR(50) NOT NULL UNIQUE COMMENT '템플릿 코드', + TITLE VARCHAR(200) NOT NULL COMMENT '알림 제목 템플릿', + CONTENT TEXT NOT NULL COMMENT '알림 내용 템플릿', + TEMPLATE_TYPE VARCHAR(50) COMMENT '템플릿 타입 (EMAIL, PUSH, SMS, SYSTEM)', + DESCRIPTION VARCHAR(500) COMMENT '템플릿 설명', + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_template_code (TEMPLATE_CODE), + INDEX idx_template_type (TEMPLATE_TYPE), + INDEX idx_is_active (IS_ACTIVE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 템플릿'; + +-- ================================================================================ +-- 7. Chatbot Domain Tables +-- ================================================================================ + +-- Chat Session Table +CREATE TABLE TBL_CHAT_SESSIONS ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + SESSION_ID VARCHAR(255) NOT NULL UNIQUE COMMENT '세션 ID', + USER_ID BIGINT NOT NULL, + TITLE VARCHAR(200) NOT NULL COMMENT '세션 제목', + DESCRIPTION VARCHAR(500) COMMENT '세션 설명', + STATUS VARCHAR(50) NOT NULL COMMENT '세션 상태 (ACTIVE, PAUSED, ENDED)', + MESSAGE_COUNT INT DEFAULT 0 COMMENT '메시지 수', + LAST_ACTIVITY_AT DATETIME COMMENT '마지막 활동 시간', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + ENDED_AT DATETIME COMMENT '종료 시간', + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_session_id (SESSION_ID), + INDEX idx_user_id (USER_ID), + INDEX idx_status (STATUS) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채팅 세션'; + +-- Chat Message Table +CREATE TABLE TBL_CHAT_MESSAGES ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NOT NULL, + MESSAGE VARCHAR(1000) NOT NULL COMMENT '사용자 메시지', + RESPONSE VARCHAR(2000) NOT NULL COMMENT '챗봇 응답', + MESSAGE_TYPE VARCHAR(50) NOT NULL COMMENT '메시지 타입 (USER, BOT)', + INTENT_TYPE VARCHAR(100) NOT NULL COMMENT '의도 타입 (GREETING, QUESTION, COMPLAINT, THANKS, GOODBYE, etc.)', + CONFIDENCE DOUBLE COMMENT '신뢰도', + SESSION_ID VARCHAR(255) COMMENT '세션 ID', + IS_HELPFUL BOOLEAN COMMENT '도움이 되었는지', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UPDATED_AT DATETIME ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (USER_ID) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_user_id (USER_ID), + INDEX idx_session_id (SESSION_ID), + INDEX idx_created_at (CREATED_AT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채팅 메시지'; + +-- ================================================================================ +-- End of CareCode Database Schema +-- ================================================================================ diff --git a/src/main/resources/db/migration/V2__feature_tables.sql b/src/main/resources/db/migration/V2__feature_tables.sql new file mode 100644 index 00000000..de7bcdb2 --- /dev/null +++ b/src/main/resources/db/migration/V2__feature_tables.sql @@ -0,0 +1,84 @@ +-- ================================================================================ +-- V2: 예방접종 일정, 커뮤니티 모더레이션, 개인정보 동의 이력 +-- +-- prod 는 ddl-auto=validate 이므로 엔티티를 추가할 때마다 이 파일 같은 +-- 포워드 마이그레이션을 함께 넣어야 기동된다. +-- ================================================================================ + +-- -------------------------------------------------------------------------------- +-- 1. 예방접종 일정 (아이 등록 시 표준 일정이 자동 생성됨) +-- -------------------------------------------------------------------------------- +CREATE TABLE TBL_VACCINATION_SCHEDULE ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + child_id BIGINT NOT NULL, + vaccine_type VARCHAR(50) NOT NULL COMMENT '백신 종류 (BCG, DTAP, MMR 등)', + dose_number INT NOT NULL COMMENT '접종 회차 (1부터)', + due_date DATE NOT NULL COMMENT '표준 일정상 접종 예정일', + completed_date DATE COMMENT '실제 접종일', + status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED' COMMENT 'SCHEDULED, COMPLETED, SKIPPED', + reminder_sent_at DATETIME COMMENT '사전 알림 발송 시각 (중복 발송 방지)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_vaccination_child FOREIGN KEY (child_id) REFERENCES TBL_CHILD(ID) ON DELETE CASCADE, + CONSTRAINT uk_vaccination_child_vaccine_dose UNIQUE (child_id, vaccine_type, dose_number), + INDEX idx_vaccination_child (child_id), + INDEX idx_vaccination_due_date (due_date), + INDEX idx_vaccination_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='아이별 예방접종 일정'; + +-- -------------------------------------------------------------------------------- +-- 2. 게시글·댓글 신고 +-- -------------------------------------------------------------------------------- +CREATE TABLE TBL_REPORT ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + reporter_id BIGINT NOT NULL, + target_type VARCHAR(20) NOT NULL COMMENT 'POST, COMMENT', + target_id BIGINT NOT NULL, + reason VARCHAR(30) NOT NULL COMMENT 'SPAM, ABUSE, SEXUAL, PRIVACY, FALSE_INFO, OTHER', + detail VARCHAR(1000), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING, ACCEPTED, REJECTED', + moderator_note VARCHAR(1000) COMMENT '관리자 처리 메모', + resolved_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_report_reporter FOREIGN KEY (reporter_id) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + CONSTRAINT uk_report_reporter_target UNIQUE (reporter_id, target_type, target_id), + INDEX idx_report_status (status), + INDEX idx_report_target (target_type, target_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='커뮤니티 신고'; + +-- -------------------------------------------------------------------------------- +-- 3. 사용자 차단 +-- -------------------------------------------------------------------------------- +CREATE TABLE TBL_USER_BLOCK ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + blocker_id BIGINT NOT NULL COMMENT '차단한 사용자', + blocked_id BIGINT NOT NULL COMMENT '차단당한 사용자', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_user_block_blocker FOREIGN KEY (blocker_id) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + CONSTRAINT fk_user_block_blocked FOREIGN KEY (blocked_id) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + CONSTRAINT uk_user_block UNIQUE (blocker_id, blocked_id), + INDEX idx_user_block_blocker (blocker_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 차단'; + +-- -------------------------------------------------------------------------------- +-- 4. 개인정보 동의 이력 (append-only) +-- +-- 현재 상태를 덮어쓰지 않고 동의·철회를 각각 새 행으로 남긴다. +-- "언제, 어떤 버전 약관에, 무엇을 동의했는지" 를 입증할 수 있어야 하기 때문이다. +-- -------------------------------------------------------------------------------- +CREATE TABLE TBL_USER_CONSENT ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + consent_type VARCHAR(40) NOT NULL COMMENT 'TERMS_OF_SERVICE, PRIVACY_POLICY, CHILD_DATA, MARKETING, THIRD_PARTY_SHARING', + policy_version VARCHAR(20) NOT NULL COMMENT '동의한 약관 버전', + granted BOOLEAN NOT NULL COMMENT '동의 여부 (철회 시 false)', + ip_address VARCHAR(45) COMMENT '동의 시점 접속 IP (분쟁 시 입증 자료)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_consent_user FOREIGN KEY (user_id) REFERENCES TBL_USER(ID) ON DELETE CASCADE, + INDEX idx_consent_user (user_id), + INDEX idx_consent_user_type (user_id, consent_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='개인정보 동의 이력'; diff --git a/src/main/resources/db/migration/V3__hospital_external_code.sql b/src/main/resources/db/migration/V3__hospital_external_code.sql new file mode 100644 index 00000000..9f5afdee --- /dev/null +++ b/src/main/resources/db/migration/V3__hospital_external_code.sql @@ -0,0 +1,13 @@ +-- ================================================================================ +-- V3: 병원 외부 식별자(심평원 암호화 요양기호) 추가 +-- +-- 공공데이터 동기화 시 같은 병원을 중복 적재하지 않기 위한 키다. +-- 심평원은 ykiho 를 암호화해 제공하며 복호화 수단이 없으므로 값 자체를 식별자로 쓴다. +-- 기존 수기 등록 병원은 NULL 로 남고, UNIQUE 제약은 NULL 을 중복으로 보지 않는다. +-- ================================================================================ + +ALTER TABLE TBL_HOSPITAL + ADD COLUMN external_code VARCHAR(100) NULL COMMENT '심평원 암호화 요양기호(ykiho)'; + +ALTER TABLE TBL_HOSPITAL + ADD CONSTRAINT uk_hospital_external_code UNIQUE (external_code); diff --git a/src/main/resources/db/migration/V4__search_indexes.sql b/src/main/resources/db/migration/V4__search_indexes.sql new file mode 100644 index 00000000..48068efb --- /dev/null +++ b/src/main/resources/db/migration/V4__search_indexes.sql @@ -0,0 +1,18 @@ +-- V4: 위치 검색 및 전문 검색 인덱스 +-- +-- 반경 검색은 그동안 모든 행에 삼각함수를 계산한 뒤 HAVING 으로 걸러 풀 스캔이었다. +-- 바운딩 박스로 후보를 좁히도록 바꾸면서 BETWEEN 조건이 인덱스를 타도록 추가한다. +-- 검색은 LIKE '%키워드%' 라 인덱스를 쓸 수 없어 FULLTEXT 를 추가한다(ngram: 한글 대응). + +-- 위치 검색: 위도로 범위를 좁히고 경도로 다시 좁힌다. +CREATE INDEX idx_facility_location ON TBL_CARE_FACILITIES (LATITUDE, LONGITUDE); +CREATE INDEX idx_hospital_location ON TBL_HOSPITAL (LATITUDE, LONGITUDE); + +-- 전문 검색 (ngram 파서는 MySQL 5.7+ / MariaDB 10.0+ 에서 한글 토큰화에 필요) +CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS) WITH PARSER ngram; +CREATE FULLTEXT INDEX ft_policy_search ON TBL_POLICIES (TITLE, DESCRIPTION) WITH PARSER ngram; +CREATE FULLTEXT INDEX ft_post_search ON TBL_POST (TITLE, CONTENT) WITH PARSER ngram; + +-- 목록 정렬에 쓰이는 컬럼 +CREATE INDEX idx_facility_active_rating ON TBL_CARE_FACILITIES (IS_ACTIVE, RATING); +CREATE INDEX idx_policy_active_created ON TBL_POLICIES (IS_ACTIVE, CREATED_AT); diff --git a/src/test/java/com/carecode/core/client/XmlResponseParserTest.java b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java new file mode 100644 index 00000000..abcb86b0 --- /dev/null +++ b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java @@ -0,0 +1,81 @@ +package com.carecode.core.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** XML 응답 파서 검증. */ +@DisplayName("XmlResponseParser") +class XmlResponseParserTest { + + private final XmlResponseParser parser = new XmlResponseParser(); + + @Test + @DisplayName("반복되는 엘리먼트를 배열로 모은다") + void collectsRepeatedElementsAsArray() { + String xml = """ + + A1행복소아과 + A2사랑소아과 + + """; + + JsonNode root = parser.parse(xml); + JsonNode items = root.path("body").path("items").path("item"); + + assertThat(items.isArray()).isTrue(); + assertThat(items).hasSize(2); + assertThat(items.get(0).path("yadmNm").asText()).isEqualTo("행복소아과"); + assertThat(items.get(1).path("ykiho").asText()).isEqualTo("A2"); + } + + @Test + @DisplayName("항목이 하나면 객체로 반환된다") + void singleItemBecomesObject() { + String xml = """ + + A1행복소아과 + + """; + + JsonNode item = parser.parse(xml).path("body").path("items").path("item"); + + assertThat(item.isObject()).isTrue(); + assertThat(item.path("yadmNm").asText()).isEqualTo("행복소아과"); + } + + @Test + @DisplayName("좌표와 전화번호 등 값 노드를 문자열로 읽는다") + void readsLeafValues() { + String xml = "127.0537.5102-123-4567"; + + JsonNode node = parser.parse(xml); + + assertThat(node.path("XPos").asText()).isEqualTo("127.05"); + assertThat(node.path("YPos").asText()).isEqualTo("37.51"); + assertThat(node.path("telno").asText()).isEqualTo("02-123-4567"); + } + + @Test + @DisplayName("XXE 공격 페이로드는 파싱하지 않는다") + void rejectsXxePayload() { + String malicious = """ + + ]> + &xxe; + """; + + // DOCTYPE 선언 자체를 막으므로 파싱이 실패하고 null 이 된다. + assertThat(parser.parse(malicious)).isNull(); + } + + @Test + @DisplayName("빈 입력과 잘못된 XML은 null을 반환한다") + void returnsNullForInvalidInput() { + assertThat(parser.parse(null)).isNull(); + assertThat(parser.parse("")).isNull(); + assertThat(parser.parse("")).isNull(); + } +} diff --git a/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java b/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java new file mode 100644 index 00000000..2ae02e7c --- /dev/null +++ b/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java @@ -0,0 +1,38 @@ +package com.carecode.core.client.constants; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("공공데이터 시도 코드") +class PublicDataApiConstantsTest { + + @Test + @DisplayName("정식 명칭으로 조회한다") + void findsByFullName() { + assertThat(PublicDataApiConstants.findSidoCode("서울특별시")).isEqualTo("11"); + assertThat(PublicDataApiConstants.findSidoCode("경기도")).isEqualTo("31"); + } + + @Test + @DisplayName("축약 표기도 매칭한다") + void findsByShortName() { + assertThat(PublicDataApiConstants.findSidoCode("서울")).isEqualTo("11"); + assertThat(PublicDataApiConstants.findSidoCode("제주")).isEqualTo("39"); + } + + @Test + @DisplayName("알 수 없는 지역은 null을 반환한다") + void returnsNullForUnknown() { + assertThat(PublicDataApiConstants.findSidoCode("존재하지않는시")).isNull(); + assertThat(PublicDataApiConstants.findSidoCode("")).isNull(); + assertThat(PublicDataApiConstants.findSidoCode(null)).isNull(); + } + + @Test + @DisplayName("전국 17개 시도가 정의돼 있다") + void coversAllProvinces() { + assertThat(PublicDataApiConstants.SIDO_CODES).hasSize(17); + } +} diff --git a/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java new file mode 100644 index 00000000..937b5cdc --- /dev/null +++ b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java @@ -0,0 +1,115 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** 공공데이터포털 공급자 검증. */ +@DisplayName("DataGoKrProvider") +class DataGoKrProviderTest { + + private static final String ENCODED_KEY = "abc%2Bdef%3D%3D"; + + @Test + @DisplayName("서비스 키가 없으면 비활성 상태다") + void inactiveWithoutServiceKey() { + DataGoKrProvider provider = new DataGoKrProvider(new RestTemplate(), "", "https://apis.data.go.kr"); + + assertThat(provider.isAvailable()).isFalse(); + assertThatThrownBy(() -> provider.fetch("some/resource", 1, 10, Map.of())) + .isInstanceOf(PublicDataApiException.class) + .hasMessageContaining("서비스 키"); + } + + @Test + @DisplayName("serviceKey를 이중 인코딩하지 않는다") + void doesNotDoubleEncodeServiceKey() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + URI uri = request.getURI(); + String query = uri.getRawQuery(); + assertThat(query).contains("serviceKey=" + ENCODED_KEY); + // 이중 인코딩되면 %252B 가 된다 + assertThat(query).doesNotContain("%252B"); + }) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"response\":{}}", MediaType.APPLICATION_JSON)); + + String body = provider.fetch("B551014/CCEF/childcare", 1, 100, Map.of()); + + assertThat(body).isEqualTo("{\"response\":{}}"); + server.verify(); + } + + @Test + @DisplayName("페이지·건수를 쿼리 파라미터로 전달한다") + void sendsPagingAsQueryParams() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + String query = request.getURI().getRawQuery(); + assertThat(query).contains("pageNo=3"); + assertThat(query).contains("numOfRows=50"); + assertThat(query).contains("sidoCd=11"); + }) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + provider.fetch("some/resource", 3, 50, Map.of("sidoCd", "11")); + + server.verify(); + } + + @Test + @DisplayName("절대 URL 리소스는 기본 호스트를 붙이지 않는다") + void usesAbsoluteResourceUrlAsIs() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + URI uri = request.getURI(); + // 표준데이터는 별도 호스트에 있다 + assertThat(uri.getHost()).isEqualTo("api.data.go.kr"); + assertThat(uri.getPath()).isEqualTo("/openapi/tn_pubr_public_kindergarten_api"); + }) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + provider.fetch("http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api", 1, 10, Map.of()); + + server.verify(); + } + + @Test + @DisplayName("빈 파라미터 값은 쿼리에서 제외한다") + void skipsBlankParams() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> assertThat(request.getURI().getRawQuery()).doesNotContain("sidoCd")) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + java.util.Map params = new java.util.HashMap<>(); + params.put("sidoCd", ""); + provider.fetch("some/resource", 1, 10, params); + + server.verify(); + } +} diff --git a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java new file mode 100644 index 00000000..7e3affd3 --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java @@ -0,0 +1,125 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("유치원 적재") +class KindergartenUpsertServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private CareFacilityRepository repository; + private KindergartenUpsertService service; + + @BeforeEach + void setUp() { + repository = mock(CareFacilityRepository.class); + when(repository.findByFacilityCode(anyString())).thenReturn(Optional.empty()); + service = new KindergartenUpsertService(repository); + } + + @Test + @DisplayName("표준데이터 한글 필드명을 읽는다") + void mapsStandardDataFields() { + boolean isNew = service.upsert(row(""" + {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1", + "전화번호":"02-123-4567","설립유형":"공립","정원":"100","현원":"80", + "위도":"37.5","경도":"127.0","시도명":"서울특별시","시군구명":"강남구"} + """)); + + assertThat(isNew).isTrue(); + CareFacility saved = captureSaved(); + assertThat(saved.getName()).isEqualTo("행복유치원"); + assertThat(saved.getFacilityType()).isEqualTo(FacilityType.KINDERGARTEN); + assertThat(saved.getIsPublic()).isTrue(); + assertThat(saved.getAvailableSpots()).isEqualTo(20); + assertThat(saved.getLatitude()).isEqualTo(37.5); + assertThat(saved.getCity()).isEqualTo("서울특별시"); + } + + @Test + @DisplayName("영문 필드명으로 와도 동일하게 읽는다") + void mapsEnglishFieldNames() { + service.upsert(row(""" + {"kindrgrtnNm":"한빛유치원","rdnmadr":"부산광역시 해운대구 1","telno":"051-1234-5678"} + """)); + + CareFacility saved = captureSaved(); + assertThat(saved.getName()).isEqualTo("한빛유치원"); + assertThat(saved.getPhone()).isEqualTo("051-1234-5678"); + } + + @Test + @DisplayName("고유 코드가 없어도 같은 유치원은 같은 코드가 나온다") + void generatesStableCodeFromNaturalKey() { + String json = """ + {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1"} + """; + + service.upsert(row(json)); + String first = captureSaved().getFacilityCode(); + + service.upsert(row(json)); + String second = captureSaved().getFacilityCode(); + + assertThat(first).isEqualTo(second).startsWith(KindergartenUpsertService.CODE_PREFIX); + } + + @Test + @DisplayName("이름이 같아도 주소가 다르면 다른 시설로 본다") + void distinguishesSameNameDifferentAddress() { + service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"서울특별시 강남구 1\"}")); + String seoul = captureSaved().getFacilityCode(); + + service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"부산광역시 해운대구 1\"}")); + String busan = captureSaved().getFacilityCode(); + + assertThat(seoul).isNotEqualTo(busan); + } + + @Test + @DisplayName("고유 코드가 있으면 그것을 쓴다") + void prefersExternalCode() { + service.upsert(row("{\"유치원명\":\"행복유치원\",\"유치원코드\":\"K12345\"}")); + + assertThat(captureSaved().getFacilityCode()) + .isEqualTo(KindergartenUpsertService.CODE_PREFIX + "K12345"); + } + + @Test + @DisplayName("유치원명이 없으면 저장하지 않는다") + void rejectsRowWithoutName() { + assertThatThrownBy(() -> service.upsert(row("{\"소재지도로명주소\":\"서울특별시\"}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유치원명"); + } + + private CareFacility captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(CareFacility.class); + verify(repository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + return captor.getValue(); + } + + private JsonNode row(String json) { + try { + return objectMapper.readTree(json); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java b/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java new file mode 100644 index 00000000..f7c47e5e --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java @@ -0,0 +1,246 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.carecode.core.client.provider.PublicDataProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("공공데이터 페이징 동기화") +class PagedSyncTemplateTest { + + private PagedSyncTemplate template; + + @BeforeEach + void setUp() { + template = new PagedSyncTemplate(new ObjectMapper(), new XmlResponseParser()); + ReflectionTestUtils.setField(template, "maxPages", 5); + } + + @Test + @DisplayName("서비스 키가 없으면 호출하지 않고 사유를 남긴다") + void skipsWhenProviderUnavailable() { + StubProvider provider = new StubProvider(false, List.of()); + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("서비스 키"); + assertThat(provider.callCount).isZero(); + } + + @Test + @DisplayName("요청 건수보다 적게 오면 마지막 페이지로 보고 더 호출하지 않는다") + void stopsOnShortPage() { + StubProvider provider = new StubProvider(true, List.of(page(1))); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(1); + assertThat(result.getPagesProcessed()).isEqualTo(1); + assertThat(provider.callCount).isEqualTo(1); + assertThat(result.isCompleted()).isTrue(); + } + + @Test + @DisplayName("빈 응답이 오면 정상 종료한다") + void stopsOnEmptyResponse() { + StubProvider provider = new StubProvider(true, List.of(page(2))); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.isCompleted()).isTrue(); + assertThat(provider.callCount).isEqualTo(2); // 두 번째 호출에서 데이터 없음을 확인 + } + + @Test + @DisplayName("첫 페이지가 전건 실패면 매핑 불일치로 보고 즉시 중단한다") + void stopsWhenFirstPageEntirelyFails() { + StubProvider provider = new StubProvider(true, List.of(page(3), page(3))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .upsert(row -> { + throw new IllegalArgumentException("필수 코드 없음"); + }) + .build()); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("매핑 불일치"); + assertThat(result.getFailed()).isEqualTo(3); + assertThat(provider.callCount).isEqualTo(1); // 나머지 페이지를 헛돌지 않는다 + } + + @Test + @DisplayName("일부만 실패하면 계속 진행한다") + void continuesOnPartialFailure() { + StubProvider provider = new StubProvider(true, List.of(page(3))); + List seen = new ArrayList<>(); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .upsert(row -> { + int id = row.get("id").asInt(); + seen.add(id); + if (id == 1) { + throw new IllegalStateException("한 건 실패"); + } + return true; + }) + .build()); + + assertThat(seen).hasSize(3); + assertThat(result.getFailed()).isEqualTo(1); + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.isCompleted()).isTrue(); + } + + @Test + @DisplayName("필터에 걸린 행은 실패가 아니라 제외로 센다") + void countsFilteredRowsAsSkipped() { + StubProvider provider = new StubProvider(true, List.of(page(4))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(4) + .filter(row -> row.get("id").asInt() % 2 == 0) + .upsert(row -> true) + .build()); + + assertThat(result.getSkipped()).isEqualTo(2); + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.getFailed()).isZero(); + } + + @Test + @DisplayName("전건 제외는 매핑 불일치로 보지 않는다") + void doesNotStopWhenAllRowsFiltered() { + StubProvider provider = new StubProvider(true, List.of(page(3))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .filter(row -> false) + .upsert(row -> true) + .build()); + + assertThat(result.isCompleted()).isTrue(); + assertThat(result.getSkipped()).isEqualTo(3); + } + + @Test + @DisplayName("XML 로 응답해도 항목을 읽는다") + void parsesXmlResponse() { + String xml = """ + + 1 + 2 + + """; + StubProvider provider = new StubProvider(true, List.of(xml)); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(2); + } + + @Test + @DisplayName("페이지 상한에 걸리면 조용히 끝내지 않고 사유를 남긴다") + void reportsPageLimit() { + List pages = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + pages.add(page(2)); + } + StubProvider provider = new StubProvider(true, pages); + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("최대 페이지"); + assertThat(result.getPagesProcessed()).isEqualTo(5); + } + + @Test + @DisplayName("조회 실패는 사유와 함께 중단한다") + void stopsOnFetchFailure() { + StubProvider provider = new StubProvider(true, List.of()) { + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + throw new IllegalStateException("연결 시간 초과"); + } + }; + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("연결 시간 초과"); + } + + private SyncResult run(PublicDataProvider provider, Function upsert) { + return template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(2) + .upsert(upsert) + .build()); + } + + /** id 만 담은 행 n 건짜리 페이지 JSON. */ + private String page(int rowCount) { + StringBuilder sb = new StringBuilder("{\"response\":{\"body\":{\"items\":["); + for (int i = 0; i < rowCount; i++) { + sb.append(i > 0 ? "," : "").append("{\"id\":").append(i).append("}"); + } + return sb.append("]}}}").toString(); + } + + /** 페이지를 순서대로 돌려주고, 다 떨어지면 빈 응답을 준다. */ + private static class StubProvider implements PublicDataProvider { + private final boolean available; + private final List pages; + private int callCount; + + StubProvider(boolean available, List pages) { + this.available = available; + this.pages = pages; + } + + @Override + public String getProviderName() { + return "STUB"; + } + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + callCount++; + return pageNo <= pages.size() ? pages.get(pageNo - 1) : null; + } + } +} diff --git a/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java b/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java new file mode 100644 index 00000000..305c2aa5 --- /dev/null +++ b/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java @@ -0,0 +1,39 @@ +package com.carecode.core.search; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("전문 검색 키워드 정규화") +class FullTextSearchSupportTest { + + private final FullTextSearchSupport support = new FullTextSearchSupport(true); + + @Test + @DisplayName("불리언 모드 연산자를 제거한다") + void stripsBooleanOperators() { + assertThat(support.normalize("강남 +어린이집 -사립")).isEqualTo("강남 어린이집 사립"); + } + + @Test + @DisplayName("한 글자 키워드는 전문 검색으로 처리하지 않는다") + void rejectsTooShortKeyword() { + assertThat(support.normalize("가")).isNull(); + assertThat(support.canUseFullText("가")).isFalse(); + } + + @Test + @DisplayName("연산자만 있는 키워드는 무효로 본다") + void rejectsOperatorOnlyKeyword() { + assertThat(support.normalize("+++")).isNull(); + } + + @Test + @DisplayName("비활성화하면 항상 LIKE 로 폴백한다") + void fallsBackWhenDisabled() { + FullTextSearchSupport disabled = new FullTextSearchSupport(false); + + assertThat(disabled.canUseFullText("어린이집")).isFalse(); + } +} diff --git a/src/test/java/com/carecode/core/util/AgeRangeParserTest.java b/src/test/java/com/carecode/core/util/AgeRangeParserTest.java new file mode 100644 index 00000000..eec064d6 --- /dev/null +++ b/src/test/java/com/carecode/core/util/AgeRangeParserTest.java @@ -0,0 +1,92 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원대상 연령 파싱") +class AgeRangeParserTest { + + @Test + @DisplayName("개월 범위를 그대로 읽는다") + void parsesMonthRange() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("생후 12~23개월 아동"); + + assertThat(range.minMonths()).isEqualTo(12); + assertThat(range.maxMonths()).isEqualTo(23); + } + + @Test + @DisplayName("세 범위는 개월로 환산하고 상한은 해당 연도의 마지막 달로 잡는다") + void convertsYearRangeToMonths() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 3~5세 유아"); + + assertThat(range.minMonths()).isEqualTo(36); + assertThat(range.maxMonths()).isEqualTo(71); + } + + @Test + @DisplayName("만 N세 사이에 만이 반복돼도 인식한다") + void parsesRepeatedManPrefix() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 0세 ~ 만 2세"); + + assertThat(range.minMonths()).isZero(); + assertThat(range.maxMonths()).isEqualTo(35); + } + + @Test + @DisplayName("미만은 상한을 한 단위 낮춘다") + void parsesExclusiveUpperBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 7세 미만 아동"); + + assertThat(range.minMonths()).isZero(); + assertThat(range.maxMonths()).isEqualTo(83); + } + + @Test + @DisplayName("이하는 해당 연도 끝까지 포함한다") + void parsesInclusiveUpperBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 8세 이하"); + + assertThat(range.maxMonths()).isEqualTo(107); + } + + @Test + @DisplayName("이상은 상한을 두지 않는다") + void parsesLowerBoundOnly() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 6세 이상 아동"); + + assertThat(range.minMonths()).isEqualTo(72); + assertThat(range.maxMonths()).isNull(); + } + + @Test + @DisplayName("개월 미만 표기도 처리한다") + void parsesMonthBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("24개월 미만 영아"); + + assertThat(range.maxMonths()).isEqualTo(23); + } + + @Test + @DisplayName("연령 조건이 없으면 추정하지 않는다") + void returnsNullWhenNoAgeCondition() { + assertThat(AgeRangeParser.parse("임신부 및 산모")).isNull(); + assertThat(AgeRangeParser.parse("")).isNull(); + assertThat(AgeRangeParser.parse(null)).isNull(); + } + + @Test + @DisplayName("연령으로 보기 어려운 큰 숫자는 무시한다") + void ignoresImplausibleNumbers() { + assertThat(AgeRangeParser.parse("2024~2025세")).isNull(); + assertThat(AgeRangeParser.parse("500개월 미만")).isNull(); + } + + @Test + @DisplayName("역순 범위는 잘못된 표기로 보고 버린다") + void rejectsInvertedRange() { + assertThat(AgeRangeParser.parse("5~3세")).isNull(); + } +} diff --git a/src/test/java/com/carecode/core/util/BoundingBoxTest.java b/src/test/java/com/carecode/core/util/BoundingBoxTest.java new file mode 100644 index 00000000..5217d20e --- /dev/null +++ b/src/test/java/com/carecode/core/util/BoundingBoxTest.java @@ -0,0 +1,41 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("바운딩 박스 계산") +class BoundingBoxTest { + + @Test + @DisplayName("반경만큼 위도 범위를 넓힌다") + void expandsLatitudeByRadius() { + BoundingBox box = BoundingBox.around(37.5665, 126.9780, 5); + + // 위도 1도 ≈ 111km 이므로 5km 는 약 0.045도 + assertThat(box.maxLat() - box.minLat()).isCloseTo(0.09, org.assertj.core.data.Offset.offset(0.005)); + assertThat(box.minLat()).isLessThan(37.5665); + assertThat(box.maxLat()).isGreaterThan(37.5665); + } + + @Test + @DisplayName("고위도일수록 경도 범위가 넓어진다") + void widensLongitudeAtHighLatitude() { + BoundingBox seoul = BoundingBox.around(37.5, 127.0, 5); + BoundingBox equator = BoundingBox.around(0.0, 127.0, 5); + + double seoulWidth = seoul.maxLng() - seoul.minLng(); + double equatorWidth = equator.maxLng() - equator.minLng(); + assertThat(seoulWidth).isGreaterThan(equatorWidth); + } + + @Test + @DisplayName("극지방에서도 위도가 범위를 벗어나지 않는다") + void clampsLatitudeAtPoles() { + BoundingBox box = BoundingBox.around(89.9, 0.0, 100); + + assertThat(box.maxLat()).isLessThanOrEqualTo(90.0); + assertThat(box.minLat()).isGreaterThanOrEqualTo(-90.0); + } +} diff --git a/src/test/java/com/carecode/core/util/ClientIpResolverTest.java b/src/test/java/com/carecode/core/util/ClientIpResolverTest.java new file mode 100644 index 00000000..78bd8dc4 --- /dev/null +++ b/src/test/java/com/carecode/core/util/ClientIpResolverTest.java @@ -0,0 +1,65 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * X-Forwarded-For 신뢰 정책에 대한 회귀 테스트. + * + *

과거에는 이 헤더를 무조건 신뢰해서, 헤더 값만 바꾸면 + * IP 기반 rate limit 을 무한히 우회할 수 있었다. + */ +@DisplayName("ClientIpResolver") +class ClientIpResolverTest { + + @Test + @DisplayName("프록시를 신뢰하지 않으면 X-Forwarded-For 를 무시한다") + void ignoresForwardedHeaderWhenNotTrusted() { + ClientIpResolver resolver = new ClientIpResolver(false); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.1"); + request.addHeader("X-Forwarded-For", "1.2.3.4"); + + assertThat(resolver.resolve(request)).isEqualTo("10.0.0.1"); + } + + @Test + @DisplayName("헤더를 위조해도 신뢰하지 않는 설정에서는 키가 달라지지 않는다") + void spoofedHeaderCannotChangeIdentity() { + ClientIpResolver resolver = new ClientIpResolver(false); + + MockHttpServletRequest first = new MockHttpServletRequest(); + first.setRemoteAddr("10.0.0.1"); + first.addHeader("X-Forwarded-For", "1.1.1.1"); + + MockHttpServletRequest second = new MockHttpServletRequest(); + second.setRemoteAddr("10.0.0.1"); + second.addHeader("X-Forwarded-For", "2.2.2.2"); + + assertThat(resolver.resolve(first)).isEqualTo(resolver.resolve(second)); + } + + @Test + @DisplayName("프록시를 신뢰하면 X-Forwarded-For 의 첫 주소를 사용한다") + void usesFirstForwardedAddressWhenTrusted() { + ClientIpResolver resolver = new ClientIpResolver(true); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.1"); + request.addHeader("X-Forwarded-For", "1.2.3.4, 10.0.0.2, 10.0.0.3"); + + assertThat(resolver.resolve(request)).isEqualTo("1.2.3.4"); + } + + @Test + @DisplayName("신뢰 설정이어도 헤더가 없으면 원격 주소를 사용한다") + void fallsBackToRemoteAddr() { + ClientIpResolver resolver = new ClientIpResolver(true); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.1"); + + assertThat(resolver.resolve(request)).isEqualTo("10.0.0.1"); + } +} diff --git a/src/test/java/com/carecode/core/util/PageRequestUtilTest.java b/src/test/java/com/carecode/core/util/PageRequestUtilTest.java new file mode 100644 index 00000000..d3e9b61c --- /dev/null +++ b/src/test/java/com/carecode/core/util/PageRequestUtilTest.java @@ -0,0 +1,40 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 목록 API 가 무제한 조회로 되돌아가지 않도록 보장하는 테스트. + */ +@DisplayName("PageRequestUtil") +class PageRequestUtilTest { + + @Test + @DisplayName("size 가 없으면 기본값을 쓴다") + void appliesDefaultSize() { + assertThat(PageRequestUtil.normalizeSize(null)).isEqualTo(PageRequestUtil.DEFAULT_PAGE_SIZE); + } + + @Test + @DisplayName("과도한 size 는 상한으로 잘린다") + void capsExcessiveSize() { + assertThat(PageRequestUtil.normalizeSize(1_000_000)).isEqualTo(PageRequestUtil.MAX_PAGE_SIZE); + } + + @Test + @DisplayName("0 이하 size 는 기본값으로 보정된다") + void normalizesNonPositiveSize() { + assertThat(PageRequestUtil.normalizeSize(0)).isEqualTo(PageRequestUtil.DEFAULT_PAGE_SIZE); + assertThat(PageRequestUtil.normalizeSize(-5)).isEqualTo(PageRequestUtil.DEFAULT_PAGE_SIZE); + } + + @Test + @DisplayName("음수 page 는 0 으로 보정된다") + void normalizesNegativePage() { + assertThat(PageRequestUtil.normalizePage(-1)).isZero(); + assertThat(PageRequestUtil.normalizePage(null)).isZero(); + assertThat(PageRequestUtil.normalizePage(3)).isEqualTo(3); + } +} diff --git a/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java new file mode 100644 index 00000000..974133eb --- /dev/null +++ b/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java @@ -0,0 +1,154 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.CareFacilityBooking; +import com.carecode.domain.careFacility.repository.CareFacilityBookingRepository; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 예약 겹침 검증 회귀 테스트. + * + *

이전 구현은 시작 시각 ±1시간만 비교해서 (1) 기존 예약의 종료 시각을 무시했고, + * (2) 취소된 예약도 충돌로 셌으며, (3) 시설 정원과 무관하게 1건만 있어도 막았다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("시설 예약 - 겹침 검증") +class CareFacilityBookingServiceTest { + + private static final Long FACILITY_ID = 10L; + + @Mock private CareFacilityBookingRepository bookingRepository; + @Mock private CareFacilityRepository careFacilityRepository; + @Mock private UserRepository userRepository; + @Mock private UserDetails userDetails; + + @InjectMocks private CareFacilityBookingService bookingService; + + private CareFacility facility; + + @BeforeEach + void setUp() { + facility = CareFacility.builder() + .id(FACILITY_ID) + .name("행복 어린이집") + .capacity(2) + .build(); + + User user = User.builder() + .id(1L).userId("u-1").email("parent@example.com") + .name("보호자").role(UserRole.PARENT) + .build(); + + when(userDetails.getUsername()).thenReturn("u-1"); + when(userRepository.findByUserId("u-1")).thenReturn(Optional.of(user)); + when(careFacilityRepository.findById(FACILITY_ID)).thenReturn(Optional.of(facility)); + when(bookingRepository.save(any(CareFacilityBooking.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + @DisplayName("겹치는 예약이 정원 미만이면 예약된다") + void allowsBookingWhenBelowCapacity() { + when(bookingRepository.countOverlappingBookings(eq(FACILITY_ID), any(), any(), isNull())) + .thenReturn(1L); // 정원 2 중 1건 사용 + + assertThatCode(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), userDetails)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("겹치는 예약이 정원에 도달하면 거부한다") + void rejectsBookingWhenCapacityReached() { + when(bookingRepository.countOverlappingBookings(eq(FACILITY_ID), any(), any(), isNull())) + .thenReturn(2L); // 정원 2 모두 사용 + + assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), userDetails)) + .isInstanceOf(CareServiceException.class) + .hasMessageContaining("예약 가능한 자리가 없습니다"); + + verify(bookingRepository, never()).save(any(CareFacilityBooking.class)); + } + + @Test + @DisplayName("겹침 판정에 요청한 시작·종료 시각을 그대로 사용한다") + void passesRequestedIntervalToOverlapQuery() { + when(bookingRepository.countOverlappingBookings(eq(FACILITY_ID), any(), any(), isNull())) + .thenReturn(0L); + + CreateBookingRequest request = futureRequest(); + bookingService.createBooking(FACILITY_ID, request, userDetails); + + ArgumentCaptor start = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor end = ArgumentCaptor.forClass(LocalDateTime.class); + verify(bookingRepository).countOverlappingBookings( + eq(FACILITY_ID), start.capture(), end.capture(), isNull()); + + // ±1시간 하드코딩이 아니라 요청 구간 그대로여야 한다 + org.assertj.core.api.Assertions.assertThat(start.getValue()).isEqualTo(request.getStartTime()); + org.assertj.core.api.Assertions.assertThat(end.getValue()).isEqualTo(request.getEndTime()); + } + + @Test + @DisplayName("종료 시각이 시작 시각보다 빠르면 거부한다") + void rejectsInvertedInterval() { + CreateBookingRequest request = futureRequest(); + request.setEndTime(request.getStartTime().minusHours(1)); + + assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, userDetails)) + .isInstanceOf(CareServiceException.class) + .hasMessageContaining("종료 시간은 시작 시간보다"); + } + + @Test + @DisplayName("과거 시간은 예약할 수 없다") + void rejectsPastBooking() { + CreateBookingRequest request = futureRequest(); + request.setStartTime(LocalDateTime.now().minusDays(1)); + request.setEndTime(LocalDateTime.now().minusDays(1).plusHours(2)); + + assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, userDetails)) + .isInstanceOf(CareServiceException.class) + .hasMessageContaining("과거 시간"); + } + + private CreateBookingRequest futureRequest() { + LocalDateTime start = LocalDateTime.now().plusDays(1).withHour(9).withMinute(0).withSecond(0).withNano(0); + CreateBookingRequest request = new CreateBookingRequest(); + request.setStartTime(start); + request.setEndTime(start.plusHours(8)); + request.setBookingType("TEMPORARY"); + request.setChildName("아이"); + request.setChildAge(3); + request.setParentName("보호자"); + request.setParentPhone("010-0000-0000"); + return request; + } +} diff --git a/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java b/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java new file mode 100644 index 00000000..95d94533 --- /dev/null +++ b/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java @@ -0,0 +1,54 @@ +package com.carecode.domain.chatbot.rag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("챗봇 키워드 추출") +class CareKnowledgeRetrieverTest { + + private final CareKnowledgeRetriever retriever = new CareKnowledgeRetriever(null, null); + + @Test + @DisplayName("조사를 떼어내고 원형을 남긴다") + void stripsJosa() { + List keywords = retriever.extractKeywords("강남구에서 어린이집은 어디에 있나요"); + + assertThat(keywords).contains("어린이집"); + assertThat(keywords).noneMatch(k -> k.endsWith("에서")); + } + + @Test + @DisplayName("의문사·범용어는 검색어에서 제외한다") + void dropsStopWords() { + List keywords = retriever.extractKeywords("부모급여 정보 알려줘"); + + assertThat(keywords).containsExactly("부모급여"); + } + + @Test + @DisplayName("긴 토큰을 먼저 시도하도록 정렬한다") + void ordersByLengthDescending() { + List keywords = retriever.extractKeywords("아동수당 신청"); + + assertThat(keywords).containsExactly("아동수당", "신청"); + } + + @Test + @DisplayName("키워드는 최대 3개까지만 뽑는다") + void limitsKeywordCount() { + List keywords = retriever.extractKeywords("서울 강남 어린이집 보육료 지원 신청 방법"); + + assertThat(keywords).hasSize(3); + } + + @Test + @DisplayName("빈 질문이면 검색을 건너뛴다") + void returnsEmptyForBlankQuestion() { + assertThat(retriever.extractKeywords(" ")).isEmpty(); + assertThat(retriever.extractKeywords(null)).isEmpty(); + } +} diff --git a/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java b/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java new file mode 100644 index 00000000..e851f586 --- /dev/null +++ b/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java @@ -0,0 +1,178 @@ +package com.carecode.domain.community.service; + +import com.carecode.core.exception.CommentAccessDeniedException; +import com.carecode.core.exception.PostAccessDeniedException; +import com.carecode.domain.community.dto.request.CommunityUpdateCommentRequest; +import com.carecode.domain.community.dto.request.CommunityUpdatePostRequest; +import com.carecode.domain.community.entity.Comment; +import com.carecode.domain.community.entity.Post; +import com.carecode.domain.community.mapper.CommunityMapper; +import com.carecode.domain.community.repository.BookmarkRepository; +import com.carecode.domain.community.repository.CommentRepository; +import com.carecode.domain.community.repository.PostLikeRepository; +import com.carecode.domain.community.repository.PostRepository; +import com.carecode.domain.community.repository.TagRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 커뮤니티 게시글/댓글 소유권 검증에 대한 회귀 테스트. + * + *

과거에는 수정·삭제 시 작성자 확인이 전혀 없어서 + * 로그인한 사용자라면 누구나 남의 글과 댓글을 지울 수 있었다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("CommunityService - 소유권 검증") +class CommunityServiceOwnershipTest { + + private static final String OWNER_EMAIL = "owner@example.com"; + private static final String OTHER_EMAIL = "other@example.com"; + private static final String ADMIN_EMAIL = "admin@example.com"; + + @Mock private PostRepository postRepository; + @Mock private CommentRepository commentRepository; + @Mock private TagRepository tagRepository; + @Mock private UserRepository userRepository; + @Mock private PostLikeRepository postLikeRepository; + @Mock private BookmarkRepository bookmarkRepository; + @Mock private CommunityMapper communityMapper; + + @InjectMocks private CommunityService communityService; + + private User owner; + private User other; + private User admin; + private Post post; + private Comment comment; + + @BeforeEach + void setUp() { + owner = user(1L, OWNER_EMAIL, UserRole.PARENT); + other = user(2L, OTHER_EMAIL, UserRole.PARENT); + admin = user(3L, ADMIN_EMAIL, UserRole.ADMIN); + + post = Post.builder().id(100L).title("제목").content("내용").author(owner).build(); + comment = Comment.builder().id(200L).content("댓글").author(owner).post(post).build(); + + when(userRepository.findByEmailAndDeletedAtIsNull(OWNER_EMAIL)).thenReturn(Optional.of(owner)); + when(userRepository.findByEmailAndDeletedAtIsNull(OTHER_EMAIL)).thenReturn(Optional.of(other)); + when(userRepository.findByEmailAndDeletedAtIsNull(ADMIN_EMAIL)).thenReturn(Optional.of(admin)); + when(postRepository.findById(100L)).thenReturn(Optional.of(post)); + when(commentRepository.findById(200L)).thenReturn(Optional.of(comment)); + when(postRepository.save(any(Post.class))).thenAnswer(inv -> inv.getArgument(0)); + when(commentRepository.save(any(Comment.class))).thenAnswer(inv -> inv.getArgument(0)); + when(commentRepository.findByPostIdAndParentCommentIsNull(any())).thenReturn(List.of()); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("타인의 게시글은 삭제할 수 없다") + void cannotDeleteOthersPost() { + authenticateAs(OTHER_EMAIL, "PARENT"); + + assertThatThrownBy(() -> communityService.deletePost(100L)) + .isInstanceOf(PostAccessDeniedException.class); + + verify(postRepository, never()).delete(any(Post.class)); + } + + @Test + @DisplayName("타인의 게시글은 수정할 수 없다") + void cannotUpdateOthersPost() { + authenticateAs(OTHER_EMAIL, "PARENT"); + CommunityUpdatePostRequest request = new CommunityUpdatePostRequest(); + + assertThatThrownBy(() -> communityService.updatePost(100L, request)) + .isInstanceOf(PostAccessDeniedException.class); + + verify(postRepository, never()).save(any(Post.class)); + } + + @Test + @DisplayName("타인의 댓글은 삭제할 수 없다") + void cannotDeleteOthersComment() { + authenticateAs(OTHER_EMAIL, "PARENT"); + + assertThatThrownBy(() -> communityService.deleteComment(200L)) + .isInstanceOf(CommentAccessDeniedException.class); + + verify(commentRepository, never()).delete(any(Comment.class)); + } + + @Test + @DisplayName("타인의 댓글은 수정할 수 없다") + void cannotUpdateOthersComment() { + authenticateAs(OTHER_EMAIL, "PARENT"); + CommunityUpdateCommentRequest request = new CommunityUpdateCommentRequest(); + + assertThatThrownBy(() -> communityService.updateComment(200L, request)) + .isInstanceOf(CommentAccessDeniedException.class); + + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("작성자 본인은 자신의 게시글을 삭제할 수 있다") + void ownerCanDeleteOwnPost() { + authenticateAs(OWNER_EMAIL, "PARENT"); + + assertThatCode(() -> communityService.deletePost(100L)).doesNotThrowAnyException(); + + verify(postRepository).delete(post); + } + + @Test + @DisplayName("관리자는 타인의 게시글을 삭제할 수 있다") + void adminCanDeleteAnyPost() { + authenticateAs(ADMIN_EMAIL, "ADMIN"); + + assertThatCode(() -> communityService.deletePost(100L)).doesNotThrowAnyException(); + + verify(postRepository).delete(post); + } + + private void authenticateAs(String email, String role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)))); + } + + private static User user(Long id, String email, UserRole role) { + return User.builder() + .id(id) + .userId("u-" + id) + .email(email) + .name("user" + id) + .role(role) + .build(); + } +} diff --git a/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java b/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java new file mode 100644 index 00000000..98b67a9c --- /dev/null +++ b/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java @@ -0,0 +1,69 @@ +package com.carecode.domain.health.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 표준 예방접종 일정 정의 검증. + */ +@DisplayName("예방접종 표준 일정") +class VaccineTypeTest { + + @Test + @DisplayName("모든 백신은 최소 1회차 이상 정의되어 있다") + void everyVaccineHasAtLeastOneDose() { + for (VaccineType vaccine : VaccineType.all()) { + assertThat(vaccine.getTotalDoses()) + .as("%s 의 회차 수", vaccine.name()) + .isGreaterThanOrEqualTo(1); + } + } + + @Test + @DisplayName("회차가 늘어날수록 접종 시기도 늦어진다") + void doseMonthsAreNonDecreasing() { + for (VaccineType vaccine : VaccineType.all()) { + int previous = -1; + for (int dose = 1; dose <= vaccine.getTotalDoses(); dose++) { + int months = vaccine.getMonthsForDose(dose); + assertThat(months) + .as("%s %d차 접종 시기", vaccine.name(), dose) + .isGreaterThanOrEqualTo(previous); + previous = months; + } + } + } + + @Test + @DisplayName("생년월일에 개월 수를 더해 접종 예정일을 계산한다") + void calculatesDueDateFromBirthDate() { + LocalDate birthDate = LocalDate.of(2026, 1, 15); + + // DTaP 1차는 생후 2개월 + int months = VaccineType.DTAP.getMonthsForDose(1); + assertThat(months).isEqualTo(2); + assertThat(birthDate.plusMonths(months)).isEqualTo(LocalDate.of(2026, 3, 15)); + } + + @Test + @DisplayName("존재하지 않는 회차는 거부한다") + void rejectsInvalidDoseNumber() { + assertThatThrownBy(() -> VaccineType.BCG.getMonthsForDose(2)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> VaccineType.BCG.getMonthsForDose(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("아이 한 명당 생성되는 접종 일정은 20건을 넘는다") + void generatesMeaningfulScheduleCount() { + int total = VaccineType.all().stream().mapToInt(VaccineType::getTotalDoses).sum(); + + assertThat(total).isGreaterThan(20); + } +} diff --git a/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java b/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java new file mode 100644 index 00000000..3ab02f01 --- /dev/null +++ b/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java @@ -0,0 +1,101 @@ +package com.carecode.domain.health.growth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * WHO LMS 백분위 계산 검증. + * + *

기존 차트 API 는 상수(DEFAULT_NUTRITION_PROGRESS)를 반환해 또래 비교가 불가능했다. + */ +@DisplayName("성장 백분위 계산") +class GrowthPercentileCalculatorTest { + + @Test + @DisplayName("중앙값을 입력하면 50 백분위가 나온다") + void medianValueYieldsFiftiethPercentile() { + // 남아 12개월 체중 중앙값 M = 9.6479kg + Optional result = + GrowthPercentileCalculator.calculate(GrowthMetric.WEIGHT, Sex.MALE, 12, 9.6479); + + assertThat(result).isPresent(); + assertThat(result.get().percentile()).isCloseTo(50.0, within(1.0)); + assertThat(result.get().zScore()).isCloseTo(0.0, within(0.05)); + assertThat(result.get().needsAttention()).isFalse(); + } + + @Test + @DisplayName("중앙값보다 크면 50 백분위를 넘는다") + void aboveMedianYieldsHigherPercentile() { + Optional result = + GrowthPercentileCalculator.calculate(GrowthMetric.WEIGHT, Sex.MALE, 12, 11.5); + + assertThat(result).isPresent(); + assertThat(result.get().percentile()).isGreaterThan(50.0); + assertThat(result.get().zScore()).isPositive(); + } + + @Test + @DisplayName("중앙값보다 작으면 50 백분위 미만이다") + void belowMedianYieldsLowerPercentile() { + Optional result = + GrowthPercentileCalculator.calculate(GrowthMetric.WEIGHT, Sex.MALE, 12, 8.0); + + assertThat(result).isPresent(); + assertThat(result.get().percentile()).isLessThan(50.0); + assertThat(result.get().zScore()).isNegative(); + } + + @Test + @DisplayName("표에 없는 개월 수는 선형 보간한다") + void interpolatesBetweenTableEntries() { + // 9개월은 표(6, 12개월)에 없으므로 보간된다 + Optional result = + GrowthPercentileCalculator.calculate(GrowthMetric.HEIGHT, Sex.FEMALE, 9, 72.0); + + assertThat(result).isPresent(); + // 6개월(65.73) ~ 12개월(74.02) 사이의 중앙값이어야 한다 + assertThat(result.get().medianValue()).isBetween(65.7, 74.1); + } + + @Test + @DisplayName("극단값은 주의 구간으로 표시한다") + void flagsExtremeValues() { + Optional result = + GrowthPercentileCalculator.calculate(GrowthMetric.WEIGHT, Sex.MALE, 12, 5.5); + + assertThat(result).isPresent(); + assertThat(result.get().needsAttention()).isTrue(); + assertThat(result.get().interpretation()).contains("낮음"); + } + + @Test + @DisplayName("WHO 표준 적용 범위(0~60개월)를 벗어나면 계산하지 않는다") + void returnsEmptyOutsideStandardRange() { + assertThat(GrowthPercentileCalculator.calculate(GrowthMetric.WEIGHT, Sex.MALE, 72, 20.0)) + .isEmpty(); + } + + @Test + @DisplayName("0 이하 측정값은 계산하지 않는다") + void returnsEmptyForNonPositiveValue() { + assertThat(GrowthPercentileCalculator.calculate(GrowthMetric.WEIGHT, Sex.MALE, 12, 0)) + .isEmpty(); + } + + @Test + @DisplayName("성별 표기가 달라도 파싱된다") + void parsesVariousSexNotations() { + assertThat(Sex.parse("남")).contains(Sex.MALE); + assertThat(Sex.parse("MALE")).contains(Sex.MALE); + assertThat(Sex.parse("여아")).contains(Sex.FEMALE); + assertThat(Sex.parse("f")).contains(Sex.FEMALE); + assertThat(Sex.parse("unknown")).isEmpty(); + assertThat(Sex.parse(null)).isEmpty(); + } +} diff --git a/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java b/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java new file mode 100644 index 00000000..e37a63cb --- /dev/null +++ b/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java @@ -0,0 +1,122 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.domain.user.dto.response.TokenDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * JWT 토큰 종류 분리에 대한 회귀 테스트. + * + *

과거에는 Access/Refresh 를 구분하는 클레임이 없어서 + * 30일짜리 Refresh Token 으로 보호된 API 에 접근할 수 있었다. + */ +@DisplayName("JwtService - 토큰 종류 검증") +class JwtServiceTest { + + private static final String SECRET = "testJwtSecretKeyForUnitTestsMustBeAtLeast256BitsLong0123456789"; + + private JwtService jwtService; + + @BeforeEach + void setUp() { + jwtService = new JwtService(); + ReflectionTestUtils.setField(jwtService, "secret", SECRET); + ReflectionTestUtils.setField(jwtService, "issuer", "carecode-test"); + ReflectionTestUtils.setField(jwtService, "accessTokenExpiration", 3_600_000L); + ReflectionTestUtils.setField(jwtService, "refreshTokenExpiration", 2_592_000_000L); + } + + @Nested + @DisplayName("Access Token 검증") + class AccessTokenValidation { + + @Test + @DisplayName("정상 발급된 Access Token 은 통과한다") + void acceptsAccessToken() { + String accessToken = jwtService.generateAccessToken("u-1", "user@example.com", "PARENT"); + + assertThat(jwtService.validateAccessToken(accessToken)).isTrue(); + assertThat(jwtService.getTokenType(accessToken)).isEqualTo(JwtService.TOKEN_TYPE_ACCESS); + } + + @Test + @DisplayName("Refresh Token 으로는 API 인증을 통과할 수 없다") + void rejectsRefreshTokenAsAccessToken() { + String refreshToken = jwtService.generateRefreshToken("u-1", "user@example.com"); + + assertThat(jwtService.validateAccessToken(refreshToken)).isFalse(); + } + } + + @Nested + @DisplayName("Refresh Token 검증") + class RefreshTokenValidation { + + @Test + @DisplayName("정상 발급된 Refresh Token 으로 토큰을 갱신할 수 있다") + void refreshesWithRefreshToken() { + String refreshToken = jwtService.generateRefreshToken("u-1", "user@example.com"); + + TokenDto refreshed = jwtService.refreshTokens(refreshToken); + + assertThat(refreshed.getAccessToken()).isNotBlank(); + assertThat(jwtService.validateAccessToken(refreshed.getAccessToken())).isTrue(); + assertThat(jwtService.validateRefreshToken(refreshed.getRefreshToken())).isTrue(); + } + + @Test + @DisplayName("Access Token 을 Refresh 엔드포인트에 재사용할 수 없다") + void rejectsAccessTokenOnRefresh() { + String accessToken = jwtService.generateAccessToken("u-1", "user@example.com", "PARENT"); + + assertThat(jwtService.validateRefreshToken(accessToken)).isFalse(); + assertThatThrownBy(() -> jwtService.refreshTokens(accessToken)) + .isInstanceOf(BusinessException.class); + } + } + + @Test + @DisplayName("다른 서명 키로 만든 토큰은 거부한다") + void rejectsTokenSignedWithAnotherKey() { + JwtService attacker = new JwtService(); + ReflectionTestUtils.setField(attacker, "secret", + "completelyDifferentSecretKeyThatIsAlsoAtLeast256BitsLong0123456789"); + ReflectionTestUtils.setField(attacker, "issuer", "carecode-test"); + ReflectionTestUtils.setField(attacker, "accessTokenExpiration", 3_600_000L); + ReflectionTestUtils.setField(attacker, "refreshTokenExpiration", 2_592_000_000L); + + String forged = attacker.generateAccessToken("u-1", "attacker@example.com", "ADMIN"); + + assertThat(jwtService.validateAccessToken(forged)).isFalse(); + } + + @Test + @DisplayName("발급자(issuer)가 다른 토큰은 거부한다") + void rejectsTokenWithDifferentIssuer() { + JwtService other = new JwtService(); + ReflectionTestUtils.setField(other, "secret", SECRET); + ReflectionTestUtils.setField(other, "issuer", "someone-else"); + ReflectionTestUtils.setField(other, "accessTokenExpiration", 3_600_000L); + ReflectionTestUtils.setField(other, "refreshTokenExpiration", 2_592_000_000L); + + String foreignToken = other.generateAccessToken("u-1", "user@example.com", "PARENT"); + + assertThat(jwtService.validateAccessToken(foreignToken)).isFalse(); + } + + @Test + @DisplayName("만료된 토큰은 거부한다") + void rejectsExpiredToken() { + ReflectionTestUtils.setField(jwtService, "accessTokenExpiration", -1_000L); + String expired = jwtService.generateAccessToken("u-1", "user@example.com", "PARENT"); + + assertThat(jwtService.validateAccessToken(expired)).isFalse(); + } +} diff --git a/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java b/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java new file mode 100644 index 00000000..9e3c26f0 --- /dev/null +++ b/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java @@ -0,0 +1,87 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.web.FilterChainProxy; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Docker 없이도 도는 컨텍스트 로딩 테스트. + * + *

보안 필터 체인, 인터셉터, 캐시 설정 등 빈 구성이 깨지면 여기서 바로 잡힌다. + * MariaDB Testcontainers 통합 테스트는 Docker 가 없으면 스킵되므로 그 공백을 메운다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode;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=testJwtSecretKeyForContextLoadTestMustBe256BitsLong0123456789", + "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" + } +) +@DisplayName("애플리케이션 컨텍스트 로딩") +class ApplicationContextLoadTest { + + @MockBean + private RedisConnectionFactory redisConnectionFactory; + + @MockBean + private StringRedisTemplate stringRedisTemplate; + + // 메일 자동설정을 제외했으므로 대체 빈을 넣어준다 (실제 SMTP 연결은 하지 않는다). + @MockBean + private JavaMailSender javaMailSender; + + @Autowired + private ApplicationContext applicationContext; + + @Test + @DisplayName("컨텍스트가 정상적으로 기동된다") + void contextLoads() { + assertThat(applicationContext).isNotNull(); + } + + @Test + @DisplayName("보안 필터 체인이 등록되어 있다") + void securityFilterChainIsRegistered() { + FilterChainProxy proxy = applicationContext.getBean(FilterChainProxy.class); + + assertThat(proxy.getFilterChains()).isNotEmpty(); + } + + @Test + @DisplayName("Rate limit 인터셉터가 빈으로 등록되어 있다") + void rateLimitInterceptorIsRegistered() { + assertThat(applicationContext.getBeansOfType(com.carecode.core.RateLimitInterceptor.class)) + .isNotEmpty(); + assertThat(applicationContext.getBeansOfType(com.carecode.core.config.WebMvcConfig.class)) + .isNotEmpty(); + } +} diff --git a/src/test/java/com/carecode/integration/CareCodeMariaDbIntegrationTest.java b/src/test/java/com/carecode/integration/CareCodeMariaDbIntegrationTest.java index 7eeb1972..b5b65883 100644 --- a/src/test/java/com/carecode/integration/CareCodeMariaDbIntegrationTest.java +++ b/src/test/java/com/carecode/integration/CareCodeMariaDbIntegrationTest.java @@ -8,6 +8,7 @@ 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.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.MariaDBContainer; @@ -64,6 +65,10 @@ static void configureDataSource(DynamicPropertyRegistry registry) { @MockBean private StringRedisTemplate stringRedisTemplate; + // 메일 자동설정을 제외했으므로 대체 빈을 넣어준다 (실제 SMTP 연결은 하지 않는다). + @MockBean + private JavaMailSender javaMailSender; + @Autowired DataSource dataSource;