feat: 新建 patbond-pet 模块骨架(ADR-009,T2-02 前置)
- 新 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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-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-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-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
|
## Technology Stack
|
||||||
|
|
||||||
- Java 17 (build baseline; use JDK 17 for release builds)
|
- Java 17 (build baseline; use JDK 17 for release builds)
|
||||||
- Spring Boot 3.5.16
|
- Spring Boot 3.5.16
|
||||||
- Spring Cloud 2025.0.3 (OpenFeign only)
|
- 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`)
|
- Maven (use the committed Maven Wrapper `./mvnw`)
|
||||||
|
|
||||||
## Build and Test
|
## Build and Test
|
||||||
@@ -49,6 +50,8 @@ cp patbond-user/src/main/resources/application.yml.sample \
|
|||||||
patbond-user/src/main/resources/application.yml
|
patbond-user/src/main/resources/application.yml
|
||||||
cp patbond-auth/src/main/resources/application.yml.sample \
|
cp patbond-auth/src/main/resources/application.yml.sample \
|
||||||
patbond-auth/src/main/resources/application.yml
|
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:
|
Install the shared module once, then run each service in its own terminal:
|
||||||
|
|||||||
@@ -60,5 +60,25 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- user
|
- 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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-api</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>patbond-pet</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>patbond-pet</name>
|
||||||
|
<description>Pet profile and health record service for Patbond (ADR-009)</description>
|
||||||
|
|
||||||
|
<!-- M2 first-wave skeleton: web + datasource wiring and a liveness endpoint.
|
||||||
|
Flyway is intentionally absent — the single migration chain (V1..V4,
|
||||||
|
including the pet_health schema) is owned and applied by patbond-user,
|
||||||
|
the shared database's startup path. This module only reads/writes the
|
||||||
|
pet_health schema once business endpoints land (M2 second wave). -->
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-common</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<!-- No spring-boot-starter-parent in this build, so the
|
||||||
|
executable-jar repackaging must be bound explicitly. -->
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Keep the plain jar as the main artifact so other
|
||||||
|
modules can depend on this one; the runnable fat
|
||||||
|
jar gets the -exec classifier and is what the
|
||||||
|
Dockerfile ships (same pattern as user/auth). -->
|
||||||
|
<classifier>exec</classifier>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Map<String, String>> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||||
|
return ResponseEntity.status(e.getHttpStatus())
|
||||||
|
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleMalformedRequest(Exception e) {
|
||||||
|
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
|
||||||
|
return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception e) {
|
||||||
|
log.error("Unhandled exception", e);
|
||||||
|
return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<ApiResponse<Void>> failure(ErrorCode errorCode, String message) {
|
||||||
|
return ResponseEntity.status(errorCode.getHttpStatus())
|
||||||
|
.body(ApiResponse.failure(errorCode.getCode(), message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)。
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user