From 0eae1c9bec79d2f14320610aa113956d61aacd6a Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Mon, 7 Sep 2026 14:34:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20patbond-pet=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E9=AA=A8=E6=9E=B6=EF=BC=88ADR-009=EF=BC=8CT2?= =?UTF-8?q?-02=20=E5=89=8D=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 Maven 模块挂入父 pom,依赖/插件管理对齐既有 user/auth 模式 (common 依赖、starter-web/validation/jdbc、exec classifier repackage) - 骨架内容:PetApplication、/health 探活端点(含 DB 连通检查)、 GlobalExceptionHandler(同一 {code,message,data} 信封契约) - 与 user 共库只读写 pet_health schema;不携带 Flyway——单一迁移链 (V1..V4)仍由 patbond-user 启动时统一执行,flyway_schema_history 不拆 - 配置走 .sample 模式(默认端口 8083,敏感信息经环境变量注入不入库) - compose 编排纳入 pet 服务(依赖 postgres 健康 + user 先起保证迁移就绪); Dockerfile 与既有服务同模式;Readme 模块清单同步 - 测试:Testcontainers postgres:18 上下文启动冒烟 + /health 探活断言 Co-Authored-By: Claude Fable 5 --- Readme.md | 5 +- docker-compose.yml | 20 ++++ patbond-pet/Dockerfile | 9 ++ patbond-pet/pom.xml | 94 +++++++++++++++++++ .../patbond/patbond/pet/PetApplication.java | 18 ++++ .../pet/controller/HealthController.java | 36 +++++++ .../pet/web/GlobalExceptionHandler.java | 63 +++++++++++++ .../src/main/resources/application.yml.sample | 16 ++++ .../patbond/pet/PetApplicationTests.java | 18 ++++ .../pet/TestcontainersConfiguration.java | 24 +++++ .../pet/controller/HealthControllerTest.java | 35 +++++++ .../src/test/resources/application.yml | 6 ++ pom.xml | 1 + 13 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 patbond-pet/Dockerfile create mode 100644 patbond-pet/pom.xml create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/PetApplication.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthController.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java create mode 100644 patbond-pet/src/main/resources/application.yml.sample create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/PetApplicationTests.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthControllerTest.java create mode 100644 patbond-pet/src/test/resources/application.yml diff --git a/Readme.md b/Readme.md index 2d30a1f..458a69d 100644 --- a/Readme.md +++ b/Readme.md @@ -7,13 +7,14 @@ Patbond API is a Spring Boot multi-module backend. - `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies. - `patbond-user`: user service for user creation, profile lookup, and password verification. - `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002). +- `patbond-pet`: pet profile and health record service (M2, ADR-009). Shares the database with `patbond-user` and only reads/writes the `pet_health` schema; the Flyway migration chain stays owned by `patbond-user`. First-wave skeleton: liveness endpoint only. ## Technology Stack - Java 17 (build baseline; use JDK 17 for release builds) - Spring Boot 3.5.16 - Spring Cloud 2025.0.3 (OpenFeign only) -- PostgreSQL 18 + Flyway (patbond-user owns the `identity`/`media` schemas) +- PostgreSQL 18 + Flyway (patbond-user owns the migration chain: `identity`/`media`/`platform`/`pet_health` schemas) - Maven (use the committed Maven Wrapper `./mvnw`) ## Build and Test @@ -49,6 +50,8 @@ cp patbond-user/src/main/resources/application.yml.sample \ patbond-user/src/main/resources/application.yml cp patbond-auth/src/main/resources/application.yml.sample \ patbond-auth/src/main/resources/application.yml +cp patbond-pet/src/main/resources/application.yml.sample \ + patbond-pet/src/main/resources/application.yml ``` Install the shared module once, then run each service in its own terminal: diff --git a/docker-compose.yml b/docker-compose.yml index 741aedc..d0116f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,5 +60,25 @@ services: depends_on: - user + # M2(ADR-009):宠物健康档案服务。第一波为骨架(仅 /health 探活,无业务端点)。 + # 与 user 共库;Flyway 迁移链由 user 服务统一执行,故依赖 user 先起, + # 保证 pet_health schema 已就绪。 + pet: + build: ./patbond-pet + environment: + SPRING_CONFIG_LOCATION: file:/config/application.yml + PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond} + PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} + PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} + volumes: + - ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro + ports: + - "${PATBOND_PET_PORT:-8083}:8083" + depends_on: + postgres: + condition: service_healthy + user: + condition: service_started + volumes: pgdata: diff --git a/patbond-pet/Dockerfile b/patbond-pet/Dockerfile new file mode 100644 index 0000000..885a6a6 --- /dev/null +++ b/patbond-pet/Dockerfile @@ -0,0 +1,9 @@ +# Runtime image only — build the jar first: ./mvnw -pl patbond-pet -am package +# Stateless by design (ADR-007): no local state, config via env / mounted files. +FROM eclipse-temurin:17-jre +RUN useradd --system --uid 10001 patbond +USER patbond +WORKDIR /app +COPY target/patbond-pet-1.0.0-SNAPSHOT-exec.jar app.jar +EXPOSE 8083 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/patbond-pet/pom.xml b/patbond-pet/pom.xml new file mode 100644 index 0000000..f0b6069 --- /dev/null +++ b/patbond-pet/pom.xml @@ -0,0 +1,94 @@ + + + 4.0.0 + + + com.patbond.patbond + patbond-api + 1.0.0-SNAPSHOT + ../pom.xml + + + patbond-pet + jar + + patbond-pet + Pet profile and health record service for Patbond (ADR-009) + + + + + com.patbond.patbond + patbond-common + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + repackage + + + + exec + + + + + + + diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/PetApplication.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/PetApplication.java new file mode 100644 index 0000000..40f16f3 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/PetApplication.java @@ -0,0 +1,18 @@ +package com.patbond.patbond.pet; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Pet profile and health record service (M2, ADR-009: the pet_health domain + * lives in its own Maven module, not inside patbond-user). First-wave + * skeleton: configuration wiring, datasource and a liveness endpoint only — + * business endpoints follow the frozen OpenAPI contract in the second wave. + */ +@SpringBootApplication +public class PetApplication { + + public static void main(String[] args) { + SpringApplication.run(PetApplication.class, args); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthController.java new file mode 100644 index 0000000..7ee6762 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthController.java @@ -0,0 +1,36 @@ +package com.patbond.patbond.pet.controller; + +import com.patbond.patbond.common.response.ApiResponse; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * Liveness/readiness probe for the skeleton phase: confirms the service is + * up and that its datasource can reach the shared PostgreSQL. Deliberately + * outside /api/v1 so it stays unauthenticated (same reasoning as compose's + * pg_isready: infrastructure probes carry no business data). + */ +@RestController +public class HealthController { + + private final JdbcClient jdbcClient; + + public HealthController(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @GetMapping("/health") + public ApiResponse> health() { + String db; + try { + jdbcClient.sql("SELECT 1").query(Integer.class).single(); + db = "up"; + } catch (Exception e) { + db = "down"; + } + return ApiResponse.success(Map.of("status", "ok", "db", db)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..864c24e --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java @@ -0,0 +1,63 @@ +package com.patbond.patbond.pet.web; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.common.response.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** + * Single place that turns exceptions into the {code, message, data} envelope + * with a matching HTTP status (development-plan 6.1) — same contract as the + * user/auth handlers. Unexpected exceptions are logged in full but never + * leak internals to the client. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException e) { + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.failure(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage()); + return failure(ErrorCode.VALIDATION_ERROR, message); + } + + @ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class}) + public ResponseEntity> handleMalformedRequest(Exception e) { + return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("Unhandled exception", e); + return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage()); + } + + private static ResponseEntity> failure(ErrorCode errorCode, String message) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ApiResponse.failure(errorCode.getCode(), message)); + } +} diff --git a/patbond-pet/src/main/resources/application.yml.sample b/patbond-pet/src/main/resources/application.yml.sample new file mode 100644 index 0000000..9caa225 --- /dev/null +++ b/patbond-pet/src/main/resources/application.yml.sample @@ -0,0 +1,16 @@ +server: + port: ${PATBOND_PET_PORT:8083} + +spring: + application: + name: patbond-pet + datasource: + # 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 pet_health schema。 + # Flyway 迁移链(V1..V4,含 pet_health 基线)由 patbond-user 启动时统一执行, + # 本服务不携带 Flyway —— 单一 flyway_schema_history 归属不拆。 + url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond} + username: ${PATBOND_DB_USER:patbond} + password: ${PATBOND_DB_PASSWORD:patbond} + +# M2 第一波骨架:暂无 /api/v1 业务端点,故尚无 JWT 公钥配置。第二波接口 +# 落地时将与 patbond-user 同一约定接入 RS256 校验(PATBOND_JWT_PUBLIC_KEY)。 diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/PetApplicationTests.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/PetApplicationTests.java new file mode 100644 index 0000000..d278650 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/PetApplicationTests.java @@ -0,0 +1,18 @@ +package com.patbond.patbond.pet; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +/** + * Smoke test: the skeleton context (web + datasource wiring) starts against + * a disposable postgres:18. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class PetApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java new file mode 100644 index 0000000..c28d355 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java @@ -0,0 +1,24 @@ +package com.patbond.patbond.pet; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Shared Testcontainers setup: a disposable postgres:18 (the production + * target version) wired into the Spring context via @ServiceConnection. + * This module carries no Flyway (the single migration chain is owned by + * patbond-user), so tests here run against a clean database — schema-level + * assertions about pet_health live in patbond-user's migration tests. + */ +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18")); + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthControllerTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthControllerTest.java new file mode 100644 index 0000000..fa31de7 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthControllerTest.java @@ -0,0 +1,35 @@ +package com.patbond.patbond.pet.controller; + +import com.patbond.patbond.pet.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The liveness probe answers 200 with the standard envelope and reports the + * datasource as reachable (the Testcontainers database is up by definition). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void healthReportsServiceAndDatabaseUp() throws Exception { + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ok")) + .andExpect(jsonPath("$.data.db").value("up")); + } +} diff --git a/patbond-pet/src/test/resources/application.yml b/patbond-pet/src/test/resources/application.yml new file mode 100644 index 0000000..4e8b83e --- /dev/null +++ b/patbond-pet/src/test/resources/application.yml @@ -0,0 +1,6 @@ +# Test-only configuration: keeps @SpringBootTest deterministic on a clean +# checkout, where the git-ignored main application.yml does not exist. The +# datasource comes from Testcontainers (@ServiceConnection). +spring: + application: + name: patbond-pet diff --git a/pom.xml b/pom.xml index 9e799dc..a4b7675 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ patbond-common patbond-user patbond-auth + patbond-pet