Compare commits
3 Commits
0d81c38fc6
...
58576f8ebc
| Author | SHA1 | Date | |
|---|---|---|---|
| 58576f8ebc | |||
| 0eae1c9bec | |||
| 49299fb4eb |
@@ -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:
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ public enum ErrorCode {
|
|||||||
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
|
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
|
||||||
TOKEN_INVALID(40101, 401, "token 无效或过期"),
|
TOKEN_INVALID(40101, 401, "token 无效或过期"),
|
||||||
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
|
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
|
||||||
|
PET_ACCESS_DENIED(40300, 403, "无权操作该宠物"),
|
||||||
USER_NOT_FOUND(40400, 404, "用户不存在"),
|
USER_NOT_FOUND(40400, 404, "用户不存在"),
|
||||||
|
PET_NOT_FOUND(40401, 404, "宠物不存在"),
|
||||||
|
RECORD_NOT_FOUND(40402, 404, "记录不存在"),
|
||||||
USERNAME_EXISTS(40900, 409, "用户名已存在"),
|
USERNAME_EXISTS(40900, 409, "用户名已存在"),
|
||||||
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
||||||
|
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
||||||
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,8 +5,9 @@ import java.util.Set;
|
|||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event dictionary v1 (report 13 §4, auth funnel) plus the two additions this
|
* Event dictionary v1 (report 13 §4, auth funnel) plus page_viewed (report 19).
|
||||||
* ticket requires (page_viewed / health_record_action — flagged in report 19).
|
* ADR-013: health_record_action removed (client zero-reference, v2 events will
|
||||||
|
* be specific per action when M2 second wave lands).
|
||||||
* Unknown event names reject the whole event; props outside the per-event
|
* Unknown event names reject the whole event; props outside the per-event
|
||||||
* whitelist are stripped (kept event, counted warning); props whose KEY
|
* whitelist are stripped (kept event, counted warning); props whose KEY
|
||||||
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
|
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
|
||||||
@@ -34,9 +35,8 @@ public final class EventDictionary {
|
|||||||
Map.entry("auth_session_restore_succeeded", Set.of("durationMs", "usedRefresh")),
|
Map.entry("auth_session_restore_succeeded", Set.of("durationMs", "usedRefresh")),
|
||||||
Map.entry("auth_session_restore_failed",
|
Map.entry("auth_session_restore_failed",
|
||||||
Set.of("failureReason", "errorCode", "httpStatus")),
|
Set.of("failureReason", "errorCode", "httpStatus")),
|
||||||
// Ticket additions beyond dictionary v1 (see report 19):
|
// Report 19 遗留项 §2:
|
||||||
Map.entry("page_viewed", Set.of("pageName", "referrer")),
|
Map.entry("page_viewed", Set.of("pageName", "referrer"))
|
||||||
Map.entry("health_record_action", Set.of("recordType", "actionType"))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public static boolean isKnownEvent(String eventName) {
|
public static boolean isKnownEvent(String eventName) {
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
V3 baseline: pet_health schema (M2 宠物健康档案), extracted from
|
||||||
|
patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model,
|
||||||
|
lines 333-554) per iteration-2 T2-01.
|
||||||
|
|
||||||
|
Scope notes:
|
||||||
|
- 8 of the 9 pet_health objects are created here: breeds, pets, pet_owners,
|
||||||
|
pet_weight_records, vaccine_catalog, pet_vaccinations, health_events,
|
||||||
|
care_reminders.
|
||||||
|
- pet_health.health_event_media is NOT created (ADR-010: media/attachments
|
||||||
|
are cut from M2; the table's asset_id is a NOT NULL FK to media.assets and
|
||||||
|
the media upload flow does not exist yet). It is a pure additive table and
|
||||||
|
will be introduced by a later migration together with the media work.
|
||||||
|
- Cross-schema FKs into marketplace are STRIPPED (mandatory cut, T2-01):
|
||||||
|
the target model adds these four constraints at lines 1156-1166 —
|
||||||
|
* fk_vaccinations_provider (pet_vaccinations.provider_id -> marketplace.providers)
|
||||||
|
* fk_vaccinations_booking (pet_vaccinations.booking_id -> marketplace.bookings)
|
||||||
|
* fk_health_events_provider (health_events.provider_id -> marketplace.providers)
|
||||||
|
* fk_health_events_booking (health_events.booking_id -> marketplace.bookings)
|
||||||
|
The marketplace schema belongs to M5 and is not migrated in M2. The four
|
||||||
|
columns are kept as bare nullable uuid; the constraints will be re-added
|
||||||
|
by the M5 migration that creates the marketplace schema (M5 补回).
|
||||||
|
- FKs to identity.users and media.assets are kept as-is: both schemas exist
|
||||||
|
since V1 (same precedent as media.assets.owner_user_id in V1).
|
||||||
|
- updated_at triggers reuse platform.set_updated_at() created in V1.
|
||||||
|
- Structure only; the breeds / vaccine_catalog dictionary seed rows are
|
||||||
|
production reference data and live in V4 (not in db/dev).
|
||||||
|
- Never edit this file after release; subsequent changes go into V4+.
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE SCHEMA pet_health;
|
||||||
|
|
||||||
|
COMMENT ON SCHEMA pet_health IS 'Pet profile, health facts, vaccines and reminders';
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.breeds (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
species varchar(16) NOT NULL,
|
||||||
|
code varchar(64) NOT NULL UNIQUE,
|
||||||
|
display_name varchar(64) NOT NULL,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (id, species),
|
||||||
|
CONSTRAINT ck_breeds_species CHECK (species IN ('dog', 'cat', 'other')),
|
||||||
|
CONSTRAINT ck_breeds_names CHECK (
|
||||||
|
code = btrim(code) AND display_name = btrim(display_name)
|
||||||
|
AND char_length(code) BETWEEN 2 AND 64
|
||||||
|
AND char_length(display_name) BETWEEN 1 AND 64
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_breeds_species_order ON pet_health.breeds (species, sort_order, id) WHERE enabled;
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.pets (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name varchar(64) NOT NULL,
|
||||||
|
species varchar(16) NOT NULL,
|
||||||
|
breed_id uuid,
|
||||||
|
custom_breed_name varchar(64),
|
||||||
|
sex varchar(8) NOT NULL DEFAULT 'unknown',
|
||||||
|
birth_date date,
|
||||||
|
birth_date_estimated boolean NOT NULL DEFAULT false,
|
||||||
|
personality varchar(64),
|
||||||
|
avatar_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
|
||||||
|
microchip_no citext,
|
||||||
|
sterilized_on date,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
version integer NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT ck_pets_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 64),
|
||||||
|
CONSTRAINT ck_pets_species CHECK (species IN ('dog', 'cat', 'other')),
|
||||||
|
CONSTRAINT ck_pets_sex CHECK (sex IN ('male', 'female', 'unknown')),
|
||||||
|
FOREIGN KEY (breed_id, species)
|
||||||
|
REFERENCES pet_health.breeds(id, species) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT ck_pets_breed CHECK (
|
||||||
|
(breed_id IS NOT NULL AND custom_breed_name IS NULL)
|
||||||
|
OR (
|
||||||
|
breed_id IS NULL AND custom_breed_name IS NOT NULL
|
||||||
|
AND char_length(btrim(custom_breed_name)) BETWEEN 1 AND 64
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_pets_birth_date CHECK (birth_date IS NULL OR birth_date >= DATE '1990-01-01'),
|
||||||
|
CONSTRAINT ck_pets_status CHECK (status IN ('active', 'lost', 'deceased', 'archived', 'deleted')),
|
||||||
|
CONSTRAINT ck_pets_version CHECK (version >= 0),
|
||||||
|
CONSTRAINT ck_pets_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_pets_microchip
|
||||||
|
ON pet_health.pets (microchip_no)
|
||||||
|
WHERE microchip_no IS NOT NULL;
|
||||||
|
CREATE INDEX ix_pets_breed_species ON pet_health.pets (breed_id, species);
|
||||||
|
CREATE INDEX ix_pets_avatar ON pet_health.pets (avatar_asset_id);
|
||||||
|
CREATE INDEX ix_pets_active_updated ON pet_health.pets (updated_at DESC, id) WHERE status = 'active';
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.pet_owners (
|
||||||
|
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
|
||||||
|
role varchar(16) NOT NULL DEFAULT 'owner',
|
||||||
|
is_primary boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (pet_id, user_id),
|
||||||
|
CONSTRAINT ck_pet_owners_role CHECK (role IN ('owner', 'caregiver', 'viewer'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_pet_owners_user ON pet_health.pet_owners (user_id, pet_id);
|
||||||
|
CREATE UNIQUE INDEX uq_pet_primary_owner
|
||||||
|
ON pet_health.pet_owners (pet_id)
|
||||||
|
WHERE is_primary;
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.pet_weight_records (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
|
||||||
|
weight_kg numeric(6,2) NOT NULL,
|
||||||
|
measured_at timestamptz NOT NULL,
|
||||||
|
source varchar(16) NOT NULL DEFAULT 'manual',
|
||||||
|
note varchar(500),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_pet_weight CHECK (weight_kg > 0 AND weight_kg <= 500),
|
||||||
|
CONSTRAINT ck_pet_weight_source CHECK (source IN ('manual', 'clinic', 'device'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_pet_weight_pet_measured
|
||||||
|
ON pet_health.pet_weight_records (pet_id, measured_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.vaccine_catalog (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code varchar(64) NOT NULL UNIQUE,
|
||||||
|
name varchar(128) NOT NULL,
|
||||||
|
species varchar(16) NOT NULL,
|
||||||
|
description varchar(500),
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_vaccine_species CHECK (species IN ('dog', 'cat', 'other')),
|
||||||
|
CONSTRAINT ck_vaccine_names CHECK (
|
||||||
|
code = btrim(code) AND name = btrim(name)
|
||||||
|
AND char_length(code) BETWEEN 2 AND 64
|
||||||
|
AND char_length(name) BETWEEN 1 AND 128
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_vaccine_catalog_species ON pet_health.vaccine_catalog (species, name) WHERE enabled;
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.pet_vaccinations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
|
||||||
|
vaccine_id uuid NOT NULL REFERENCES pet_health.vaccine_catalog(id) ON DELETE RESTRICT,
|
||||||
|
series_key varchar(64) NOT NULL,
|
||||||
|
dose_no smallint NOT NULL,
|
||||||
|
dose_label varchar(64),
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'scheduled',
|
||||||
|
planned_on date,
|
||||||
|
administered_on date,
|
||||||
|
next_due_on date,
|
||||||
|
-- provider_id / booking_id: bare nullable uuid, FKs to marketplace stripped (M5 补回)
|
||||||
|
provider_id uuid,
|
||||||
|
provider_name_snapshot varchar(128),
|
||||||
|
manufacturer varchar(128),
|
||||||
|
batch_no varchar(64),
|
||||||
|
certificate_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
|
||||||
|
booking_id uuid,
|
||||||
|
notes varchar(1000),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
version integer NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT ck_vaccination_series CHECK (series_key = btrim(series_key) AND char_length(series_key) BETWEEN 1 AND 64),
|
||||||
|
CONSTRAINT ck_vaccination_dose CHECK (dose_no > 0),
|
||||||
|
CONSTRAINT ck_vaccination_status CHECK (status IN ('scheduled', 'completed', 'cancelled')),
|
||||||
|
CONSTRAINT ck_vaccination_dates CHECK (
|
||||||
|
(status = 'completed' AND administered_on IS NOT NULL)
|
||||||
|
OR (status = 'scheduled' AND administered_on IS NULL AND planned_on IS NOT NULL)
|
||||||
|
OR (status = 'cancelled' AND administered_on IS NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_vaccination_next_due CHECK (
|
||||||
|
next_due_on IS NULL OR administered_on IS NULL OR next_due_on >= administered_on
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_vaccination_version CHECK (version >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_pet_vaccination_dose
|
||||||
|
ON pet_health.pet_vaccinations (pet_id, vaccine_id, series_key, dose_no)
|
||||||
|
WHERE status <> 'cancelled';
|
||||||
|
CREATE INDEX ix_vaccinations_pet ON pet_health.pet_vaccinations (pet_id);
|
||||||
|
CREATE INDEX ix_vaccinations_vaccine ON pet_health.pet_vaccinations (vaccine_id);
|
||||||
|
CREATE INDEX ix_vaccinations_certificate ON pet_health.pet_vaccinations (certificate_asset_id);
|
||||||
|
CREATE INDEX ix_vaccinations_provider ON pet_health.pet_vaccinations (provider_id);
|
||||||
|
CREATE INDEX ix_vaccinations_booking ON pet_health.pet_vaccinations (booking_id);
|
||||||
|
CREATE INDEX ix_vaccinations_pet_completed
|
||||||
|
ON pet_health.pet_vaccinations (pet_id, administered_on DESC, id DESC)
|
||||||
|
WHERE status = 'completed';
|
||||||
|
CREATE INDEX ix_vaccinations_due
|
||||||
|
ON pet_health.pet_vaccinations (planned_on, pet_id)
|
||||||
|
WHERE status = 'scheduled';
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.health_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
|
||||||
|
event_type varchar(24) NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
title varchar(160) NOT NULL,
|
||||||
|
notes text,
|
||||||
|
amount_cents bigint,
|
||||||
|
-- provider_id / booking_id: bare nullable uuid, FKs to marketplace stripped (M5 补回)
|
||||||
|
provider_id uuid,
|
||||||
|
provider_name_snapshot varchar(128),
|
||||||
|
booking_id uuid,
|
||||||
|
created_by_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
version integer NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT ck_health_event_type CHECK (
|
||||||
|
event_type IN ('medical', 'feeding', 'deworming', 'grooming', 'measurement', 'note')
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_health_event_title CHECK (title = btrim(title) AND char_length(title) BETWEEN 1 AND 160),
|
||||||
|
CONSTRAINT ck_health_event_amount CHECK (amount_cents IS NULL OR amount_cents >= 0),
|
||||||
|
CONSTRAINT ck_health_event_version CHECK (version >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_health_events_pet_time ON pet_health.health_events (pet_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX ix_health_events_creator ON pet_health.health_events (created_by_user_id, created_at DESC);
|
||||||
|
CREATE INDEX ix_health_events_provider ON pet_health.health_events (provider_id);
|
||||||
|
CREATE INDEX ix_health_events_booking ON pet_health.health_events (booking_id);
|
||||||
|
|
||||||
|
CREATE TABLE pet_health.care_reminders (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
|
||||||
|
reminder_type varchar(24) NOT NULL,
|
||||||
|
title varchar(160) NOT NULL,
|
||||||
|
due_at timestamptz NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
completed_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_care_reminder_type CHECK (
|
||||||
|
reminder_type IN ('deworming', 'checkup', 'medication', 'other')
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_care_reminder_status CHECK (status IN ('pending', 'completed', 'dismissed')),
|
||||||
|
CONSTRAINT ck_care_reminder_completed CHECK ((status = 'completed') = (completed_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_care_reminders_pet ON pet_health.care_reminders (pet_id);
|
||||||
|
CREATE INDEX ix_care_reminders_due
|
||||||
|
ON pet_health.care_reminders (due_at, pet_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- Automatic updated_at maintenance (function created in V1). Business version
|
||||||
|
-- increments remain explicit so optimistic locking stays visible in
|
||||||
|
-- repository update statements.
|
||||||
|
CREATE TRIGGER trg_pets_updated_at BEFORE UPDATE ON pet_health.pets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||||
|
CREATE TRIGGER trg_vaccinations_updated_at BEFORE UPDATE ON pet_health.pet_vaccinations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||||
|
CREATE TRIGGER trg_health_events_updated_at BEFORE UPDATE ON pet_health.health_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||||
|
CREATE TRIGGER trg_reminders_updated_at BEFORE UPDATE ON pet_health.care_reminders
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
V4: breeds / vaccine_catalog dictionary seed (iteration-2 T2-01, D2-6).
|
||||||
|
|
||||||
|
These are production reference data (business rows the app FKs against),
|
||||||
|
not development fixtures, so they live in the versioned chain rather than
|
||||||
|
db/dev. The set is a curated development-grade starter list (10~20 common
|
||||||
|
entries per species); the authoritative catalog is a separate content task
|
||||||
|
owned by the product side (D2-6) and will land as later migrations.
|
||||||
|
|
||||||
|
Rows use the column DEFAULT gen_random_uuid() for ids: dictionary rows are
|
||||||
|
server-born reference data, the application-side UUIDv7 rule applies to
|
||||||
|
rows created through the API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
INSERT INTO pet_health.breeds (species, code, display_name, sort_order) VALUES
|
||||||
|
('dog', 'mixed_dog', '中华田园犬', 10),
|
||||||
|
('dog', 'golden_retriever', '金毛寻回犬', 20),
|
||||||
|
('dog', 'labrador_retriever', '拉布拉多犬', 30),
|
||||||
|
('dog', 'poodle', '贵宾犬', 40),
|
||||||
|
('dog', 'bichon_frise', '比熊犬', 50),
|
||||||
|
('dog', 'pomeranian', '博美犬', 60),
|
||||||
|
('dog', 'welsh_corgi', '威尔士柯基犬', 70),
|
||||||
|
('dog', 'shiba_inu', '柴犬', 80),
|
||||||
|
('dog', 'siberian_husky', '哈士奇', 90),
|
||||||
|
('dog', 'samoyed', '萨摩耶犬', 100),
|
||||||
|
('dog', 'border_collie', '边境牧羊犬', 110),
|
||||||
|
('dog', 'french_bulldog', '法国斗牛犬', 120),
|
||||||
|
('dog', 'chihuahua', '吉娃娃', 130),
|
||||||
|
('dog', 'dachshund', '腊肠犬', 140),
|
||||||
|
('dog', 'schnauzer', '雪纳瑞', 150),
|
||||||
|
('dog', 'german_shepherd', '德国牧羊犬', 160),
|
||||||
|
('cat', 'mixed_cat', '中华田园猫', 10),
|
||||||
|
('cat', 'british_shorthair', '英国短毛猫', 20),
|
||||||
|
('cat', 'american_shorthair', '美国短毛猫', 30),
|
||||||
|
('cat', 'ragdoll', '布偶猫', 40),
|
||||||
|
('cat', 'siamese', '暹罗猫', 50),
|
||||||
|
('cat', 'persian', '波斯猫', 60),
|
||||||
|
('cat', 'maine_coon', '缅因猫', 70),
|
||||||
|
('cat', 'scottish_fold', '苏格兰折耳猫', 80),
|
||||||
|
('cat', 'exotic_shorthair', '异国短毛猫', 90),
|
||||||
|
('cat', 'russian_blue', '俄罗斯蓝猫', 100),
|
||||||
|
('cat', 'bengal', '孟加拉豹猫', 110),
|
||||||
|
('cat', 'sphynx', '斯芬克斯猫', 120);
|
||||||
|
|
||||||
|
INSERT INTO pet_health.vaccine_catalog (code, name, species, description) VALUES
|
||||||
|
('canine_2in1', '犬二联疫苗', 'dog', '预防犬瘟热、犬细小病毒'),
|
||||||
|
('canine_4in1', '犬四联疫苗', 'dog', '预防犬瘟热、犬细小病毒、犬传染性肝炎、副流感'),
|
||||||
|
('canine_5in1', '犬五联疫苗', 'dog', '预防犬瘟热、犬细小病毒、犬传染性肝炎、副流感、腺病毒 II 型'),
|
||||||
|
('canine_8in1', '犬八联疫苗', 'dog', '五联基础上增加钩端螺旋体等'),
|
||||||
|
('rabies_dog', '狂犬疫苗(犬)', 'dog', '狂犬病毒灭活疫苗,首免后按年加强'),
|
||||||
|
('kennel_cough', '犬窝咳疫苗', 'dog', '预防支气管败血波氏杆菌引起的犬窝咳'),
|
||||||
|
('feline_3in1', '猫三联疫苗', 'cat', '预防猫瘟、猫杯状病毒、猫疱疹病毒'),
|
||||||
|
('rabies_cat', '狂犬疫苗(猫)', 'cat', '狂犬病毒灭活疫苗,首免后按年加强'),
|
||||||
|
('felv', '猫白血病疫苗', 'cat', '预防猫白血病病毒感染'),
|
||||||
|
('feline_chlamydia', '猫衣原体疫苗', 'cat', '预防猫衣原体感染');
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.patbond.patbond.user.analytics;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the dictionary's whitelist boundaries: ADR-013 removed the
|
||||||
|
* health_record_action placeholder (client zero-reference); v1 auth funnel
|
||||||
|
* events plus page_viewed remain the complete set until dictionary v2 lands
|
||||||
|
* with M2's second wave.
|
||||||
|
*/
|
||||||
|
class EventDictionaryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void healthRecordActionIsRemovedPerAdr013() {
|
||||||
|
assertThat(EventDictionary.isKnownEvent("health_record_action")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v1EventsAndPageViewedRemainKnown() {
|
||||||
|
assertThat(EventDictionary.isKnownEvent("auth_register_started")).isTrue();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("auth_login_succeeded")).isTrue();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("page_viewed")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownEventHasEmptyAllowedProps() {
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_action")).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package com.patbond.patbond.user.persistence;
|
||||||
|
|
||||||
|
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flyway V3/V4 (pet_health schema baseline + dictionary seed) apply cleanly
|
||||||
|
* on a postgres:18 container and produce the expected structure. Validates
|
||||||
|
* the mandatory cross-schema FK cuts (ADR-013, T2-01): provider_id/booking_id
|
||||||
|
* exist as bare nullable uuid columns without FKs to marketplace.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class PetHealthMigrationIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3CreatesPetHealthSchema() {
|
||||||
|
String exists = jdbcClient.sql(
|
||||||
|
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'pet_health'")
|
||||||
|
.query(String.class)
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
assertThat(exists).isEqualTo("pet_health");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3CreatesAllPetHealthTables() {
|
||||||
|
int count = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_type = 'BASE TABLE'")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
// 8 tables: breeds, pets, pet_owners, pet_weight_records, vaccine_catalog,
|
||||||
|
// pet_vaccinations, health_events, care_reminders.
|
||||||
|
// health_event_media is NOT created (ADR-010: media cut from M2).
|
||||||
|
assertThat(count).isEqualTo(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3PetsTableHasExpectedStructure() {
|
||||||
|
// Sample columns and constraints spot-check
|
||||||
|
String microchipType = jdbcClient.sql(
|
||||||
|
"SELECT udt_name FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_name = 'pets' " +
|
||||||
|
"AND column_name = 'microchip_no'")
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(microchipType).isEqualTo("citext");
|
||||||
|
|
||||||
|
String versionDefault = jdbcClient.sql(
|
||||||
|
"SELECT column_default FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_name = 'pets' " +
|
||||||
|
"AND column_name = 'version'")
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(versionDefault).isEqualTo("0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3VaccinationsColumnsExistWithoutMarketplaceFKs() {
|
||||||
|
// provider_id and booking_id columns exist (bare nullable uuid)
|
||||||
|
int colCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_name = 'pet_vaccinations' " +
|
||||||
|
"AND column_name IN ('provider_id', 'booking_id')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(colCount).isEqualTo(2);
|
||||||
|
|
||||||
|
// No FK constraints targeting marketplace (marketplace schema does not exist in M2)
|
||||||
|
int fkCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_name = 'pet_vaccinations' " +
|
||||||
|
"AND constraint_type = 'FOREIGN KEY' " +
|
||||||
|
"AND (constraint_name LIKE '%provider%' OR constraint_name LIKE '%booking%')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(fkCount).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3HealthEventsColumnsExistWithoutMarketplaceFKs() {
|
||||||
|
// provider_id and booking_id columns exist (bare nullable uuid)
|
||||||
|
int colCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_name = 'health_events' " +
|
||||||
|
"AND column_name IN ('provider_id', 'booking_id')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(colCount).isEqualTo(2);
|
||||||
|
|
||||||
|
// No FK constraints targeting marketplace
|
||||||
|
int fkCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
||||||
|
"WHERE table_schema = 'pet_health' AND table_name = 'health_events' " +
|
||||||
|
"AND constraint_type = 'FOREIGN KEY' " +
|
||||||
|
"AND (constraint_name LIKE '%provider%' OR constraint_name LIKE '%booking%')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(fkCount).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3TriggersAreCreated() {
|
||||||
|
// The four updated_at triggers on pets, pet_vaccinations, health_events, care_reminders
|
||||||
|
int triggerCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.triggers " +
|
||||||
|
"WHERE trigger_schema = 'pet_health' " +
|
||||||
|
"AND trigger_name IN ('trg_pets_updated_at', 'trg_vaccinations_updated_at', " +
|
||||||
|
"'trg_health_events_updated_at', 'trg_reminders_updated_at')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(triggerCount).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v4SeedsBreedsAndVaccineCatalog() {
|
||||||
|
int breedCount = jdbcClient.sql("SELECT COUNT(*) FROM pet_health.breeds")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(breedCount).isGreaterThan(0).describedAs("V4 应插入 breeds 种子数据");
|
||||||
|
|
||||||
|
int vaccineCount = jdbcClient.sql("SELECT COUNT(*) FROM pet_health.vaccine_catalog")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(vaccineCount).isGreaterThan(0).describedAs("V4 应插入 vaccine_catalog 种子数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v4SeedsIncludeMixedDogAndCat() {
|
||||||
|
// Spot-check a couple of known rows from V4
|
||||||
|
String mixedDog = jdbcClient.sql(
|
||||||
|
"SELECT display_name FROM pet_health.breeds WHERE code = 'mixed_dog'")
|
||||||
|
.query(String.class)
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
assertThat(mixedDog).isEqualTo("中华田园犬");
|
||||||
|
|
||||||
|
String rabiesDog = jdbcClient.sql(
|
||||||
|
"SELECT name FROM pet_health.vaccine_catalog WHERE code = 'rabies_dog'")
|
||||||
|
.query(String.class)
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
assertThat(rabiesDog).isEqualTo("狂犬疫苗(犬)");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user