Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c671fcaf5 | |||
| a97814ac1c |
@@ -82,5 +82,28 @@ services:
|
|||||||
user:
|
user:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
|
# M3(ADR-017):社区服务(Feed/帖子/评论/互动)。骨架起 /api/v1/** 即接
|
||||||
|
# RS256 校验(与 user/pet 同一公钥);只读写 community schema。与 user 共库;
|
||||||
|
# Flyway 迁移链(V1..V5,含 community 基线)由 user 服务统一执行,故依赖
|
||||||
|
# user 先起,保证 community schema 已就绪。
|
||||||
|
community:
|
||||||
|
build: ./patbond-community
|
||||||
|
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}
|
||||||
|
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||||
|
volumes:
|
||||||
|
- ./patbond-community/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_COMMUNITY_PORT:-8084}:8084"
|
||||||
|
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-community -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-community-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||||
|
EXPOSE 8084
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?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-community</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>patbond-community</name>
|
||||||
|
<description>Community feed, posts and interactions service for Patbond (ADR-017)</description>
|
||||||
|
|
||||||
|
<!-- M3 first-wave skeleton: RS256 bearer auth on /api/v1/** (same JWT
|
||||||
|
verification stack as patbond-user/pet), community schema access via
|
||||||
|
JDBC. Flyway remains absent — the migration chain is owned by
|
||||||
|
patbond-user. -->
|
||||||
|
<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>
|
||||||
|
<!-- Access token verification (RS256, public key only): jjwt is not in
|
||||||
|
the Boot BOM, version pinned in step with patbond-user/auth/pet. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Tests need the community schema. The migration chain (V1..V5) is
|
||||||
|
owned by patbond-user (single flyway_schema_history); pulling its
|
||||||
|
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
|
||||||
|
auto-config apply the same chain to the disposable container.
|
||||||
|
Production wiring is unchanged: this module still ships without
|
||||||
|
Flyway and the chain runs in patbond-user's startup path (same
|
||||||
|
mechanism as patbond-pet). -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-user</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</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/pet). -->
|
||||||
|
<classifier>exec</classifier>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community feed, posts and interactions service (M3, ADR-017: the community
|
||||||
|
* domain lives in its own Maven module on :8084). First-wave skeleton:
|
||||||
|
* configuration wiring, datasource, RS256 bearer auth on /api/v1/** and a
|
||||||
|
* liveness endpoint — business endpoints follow the contract work in the
|
||||||
|
* next waves. The module only reads and writes the community schema
|
||||||
|
* (author profile lookups follow the D3-9 plan later).
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class CommunityApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(CommunityApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security knobs of the community service: only the RS256 public key for
|
||||||
|
* verifying access tokens issued by patbond-auth (same contract as
|
||||||
|
* patbond-user/pet's {@code patbond.jwt.public-key}). No /internal routes
|
||||||
|
* exist here yet, so no service token property.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "patbond")
|
||||||
|
public class CommunitySecurityProperties {
|
||||||
|
|
||||||
|
private final Jwt jwt = new Jwt();
|
||||||
|
|
||||||
|
public Jwt getJwt() {
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Jwt {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RS256 public key for verifying access tokens signed by
|
||||||
|
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
|
||||||
|
* filesystem path. The private key never reaches this service.
|
||||||
|
*/
|
||||||
|
private String publicKey;
|
||||||
|
|
||||||
|
public String getPublicKey() {
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublicKey(String publicKey) {
|
||||||
|
this.publicKey = publicKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integer fields must reject decimal input (development-plan 4.3) — Jackson's
|
||||||
|
* default is to silently truncate 45.5 to 45 in an integer field, which
|
||||||
|
* would corrupt values instead of rejecting them. Same setting as the other
|
||||||
|
* services so all envelopes behave identically.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
|
||||||
|
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.security.JwtVerifier;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires bearer authentication for /api/v1/** without pulling in
|
||||||
|
* spring-security — the same single-filter pattern patbond-user and
|
||||||
|
* patbond-pet use. The /health probe stays outside /api/v1 and therefore
|
||||||
|
* unauthenticated.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(CommunitySecurityProperties.class)
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JwtVerifier jwtVerifier(CommunitySecurityProperties properties) {
|
||||||
|
return new JwtVerifier(properties.getJwt().getPublicKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
|
||||||
|
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||||
|
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
|
||||||
|
new BearerAuthFilter(jwtVerifier, objectMapper));
|
||||||
|
registration.addUrlPatterns("/api/v1/*");
|
||||||
|
registration.setOrder(20);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.patbond.patbond.community.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer authentication for /api/v1/** community endpoints. Verifies the
|
||||||
|
* RS256 signature locally with the auth service's public key and exposes the
|
||||||
|
* authenticated user id as a request attribute. Missing, forged or expired
|
||||||
|
* tokens all answer 401/40101 without detail. Third copy of the user/pet
|
||||||
|
* filter — sinking the shared pure-Java parts into patbond-common stays a
|
||||||
|
* separate decision (iteration-3/02 P9, not ratified in ADR-016~021).
|
||||||
|
*/
|
||||||
|
public class BearerAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
/** Request attribute holding the authenticated user's UUID. */
|
||||||
|
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
|
||||||
|
|
||||||
|
private final JwtVerifier jwtVerifier;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||||
|
this.jwtVerifier = jwtVerifier;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
return !request.getRequestURI().startsWith("/api/v1/");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header == null || !header.startsWith("Bearer ")) {
|
||||||
|
reject(response, ErrorCode.TOKEN_INVALID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
|
||||||
|
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
reject(response, ErrorCode.TOKEN_INVALID);
|
||||||
|
return;
|
||||||
|
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||||
|
log.error("Access token verification unavailable: {}", e.getMessage());
|
||||||
|
reject(response, ErrorCode.INTERNAL_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
|
||||||
|
response.setStatus(errorCode.getHttpStatus());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
objectMapper.writeValue(response.getWriter(),
|
||||||
|
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
|
import io.jsonwebtoken.JwtParser;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies RS256 access tokens issued by patbond-auth against the configured
|
||||||
|
* public key. The key is optional at startup so contexts that never serve
|
||||||
|
* protected routes (most tests) can boot without one; any verification
|
||||||
|
* attempt without a key fails loudly as a server misconfiguration instead of
|
||||||
|
* being reported to the client as an authentication problem.
|
||||||
|
*/
|
||||||
|
public class JwtVerifier {
|
||||||
|
|
||||||
|
private final JwtParser parser;
|
||||||
|
|
||||||
|
public JwtVerifier(String publicKeyLocation) {
|
||||||
|
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
|
||||||
|
? null
|
||||||
|
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the verified claims
|
||||||
|
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||||
|
*/
|
||||||
|
public Claims verify(String token) {
|
||||||
|
if (parser == null) {
|
||||||
|
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an RSA public key for JWT signature verification. Accepts either
|
||||||
|
* inline PEM or a filesystem path; the inline form (environment variable
|
||||||
|
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
|
||||||
|
* because mounted secrets beat files-in-the-image.
|
||||||
|
*/
|
||||||
|
public final class RsaPublicKeyLoader {
|
||||||
|
|
||||||
|
private RsaPublicKeyLoader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublicKey load(String pemOrPath) {
|
||||||
|
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
|
||||||
|
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
|
||||||
|
.replaceAll("\\s", "");
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(stripped);
|
||||||
|
try {
|
||||||
|
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||||
|
throw new IllegalArgumentException("Invalid RSA public key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readFile(String path) {
|
||||||
|
try {
|
||||||
|
return Files.readString(Path.of(path));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalArgumentException("Cannot read public key from " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.patbond.patbond.community.web;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
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.HandlerMethodValidationException;
|
||||||
|
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/pet 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,
|
||||||
|
ConstraintViolationException.class, HandlerMethodValidationException.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,22 @@
|
|||||||
|
server:
|
||||||
|
port: ${PATBOND_COMMUNITY_PORT:8084}
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: patbond-community
|
||||||
|
datasource:
|
||||||
|
# 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 community schema
|
||||||
|
# (作者公开资料按 D3-9 方案 B 走 user 的 /internal 批量接口,后续波次落地)。
|
||||||
|
# Flyway 迁移链(V1..V5,含 community 基线)由 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}
|
||||||
|
|
||||||
|
# /api/v1/** 业务端点自骨架起即接 RS256 校验,与 patbond-user/pet 同一约定。
|
||||||
|
patbond:
|
||||||
|
jwt:
|
||||||
|
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
|
||||||
|
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||||
|
# 私钥只给 patbond-auth,绝不入库。
|
||||||
|
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context smoke test: the skeleton boots against a clean postgres:18 with
|
||||||
|
* the full V1..V5 migration chain applied from the test classpath.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class CommunityApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
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.
|
||||||
|
* The production module carries no Flyway (the single migration chain is
|
||||||
|
* owned by patbond-user), but the TEST classpath adds patbond-user's jar
|
||||||
|
* plus Flyway, so Boot applies the full V1..V5 chain — including the
|
||||||
|
* community schema these tests exercise — to the fresh container exactly
|
||||||
|
* as the shared database gets it in production (same mechanism as
|
||||||
|
* patbond-pet).
|
||||||
|
*/
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
public class TestcontainersConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ServiceConnection
|
||||||
|
PostgreSQLContainer<?> postgresContainer() {
|
||||||
|
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.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).
|
||||||
|
* It sits outside /api/v1, so no token is needed.
|
||||||
|
*/
|
||||||
|
@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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.TestcontainersConfiguration;
|
||||||
|
import com.patbond.patbond.community.support.TestJwtKeys;
|
||||||
|
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.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
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 BearerAuthFilter guards every /api/v1/** route from the skeleton on
|
||||||
|
* (T3-02 acceptance): missing, forged, wrong-key and expired tokens all
|
||||||
|
* answer 401 with the 40101 envelope, while a valid token passes the filter
|
||||||
|
* (and reaches the 404 of a not-yet-implemented route instead of a 401).
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class BearerAuthIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void jwtPublicKey(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingTokenAnswers401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/v1/posts"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedTokenAnswers401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/v1/posts")
|
||||||
|
.header("Authorization", "Bearer not-a-jwt"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongKeyTokenAnswers401() throws Exception {
|
||||||
|
String forged = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(),
|
||||||
|
UUID.randomUUID(), Duration.ofMinutes(15));
|
||||||
|
mockMvc.perform(get("/api/v1/posts")
|
||||||
|
.header("Authorization", "Bearer " + forged))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredTokenAnswers401() throws Exception {
|
||||||
|
String expired = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||||
|
UUID.randomUUID(), Duration.ofMinutes(-5));
|
||||||
|
mockMvc.perform(get("/api/v1/posts")
|
||||||
|
.header("Authorization", "Bearer " + expired))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validTokenPassesTheFilter() throws Exception {
|
||||||
|
// No business routes exist in the skeleton, so an authenticated
|
||||||
|
// request reaches the 404 envelope — proving the filter let it in.
|
||||||
|
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||||
|
UUID.randomUUID(), Duration.ofMinutes(15));
|
||||||
|
mockMvc.perform(get("/api/v1/posts")
|
||||||
|
.header("Authorization", "Bearer " + token))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40400));
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-generated RSA material for JWT tests. Nothing here is committed
|
||||||
|
* key material (git-workflow: no credentials in the repository) — every test
|
||||||
|
* run mints a fresh pair and injects the public key via
|
||||||
|
* {@code @DynamicPropertySource}.
|
||||||
|
*/
|
||||||
|
public final class TestJwtKeys {
|
||||||
|
|
||||||
|
public static final KeyPair KEY_PAIR = generate();
|
||||||
|
/** A second pair, for signing tokens the service must reject. */
|
||||||
|
public static final KeyPair WRONG_KEY_PAIR = generate();
|
||||||
|
|
||||||
|
private TestJwtKeys() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String publicPem() {
|
||||||
|
return "-----BEGIN PUBLIC KEY-----\n"
|
||||||
|
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
|
||||||
|
+ "\n-----END PUBLIC KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
|
||||||
|
public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.id(UUID.randomUUID().toString())
|
||||||
|
.subject(userId.toString())
|
||||||
|
.issuer("patbond-auth")
|
||||||
|
.claim("sid", UUID.randomUUID().toString())
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plus(ttl)))
|
||||||
|
.signWith(key, Jwts.SIG.RS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyPair generate() {
|
||||||
|
try {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
return generator.generateKeyPair();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-community
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/*
|
||||||
|
V5 baseline: community schema (M3 社区), extracted from
|
||||||
|
patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model,
|
||||||
|
lines 718-875) per iteration-3 T3-01 (ADR-017/ADR-018).
|
||||||
|
|
||||||
|
Scope notes:
|
||||||
|
- All 8 community objects are created here: posts, post_media, comments,
|
||||||
|
post_likes, post_bookmarks, user_follows, topics, post_topics. The topics
|
||||||
|
tables follow the target model even though the topic feature itself is cut
|
||||||
|
from the M3 MVP (ADR-018): structure tracks the target model, feature
|
||||||
|
rollout does not. No topic seed data enters the production chain.
|
||||||
|
- pg_trgm is enabled here: V1 created only pgcrypto and citext, but
|
||||||
|
ix_posts_content_trgm (gin_trgm_ops) requires it (iteration-3/02 finding).
|
||||||
|
citext is repeated with IF NOT EXISTS for idempotence; it exists since V1.
|
||||||
|
- Cross-schema FKs into schemas not yet migrated are STRIPPED (mandatory
|
||||||
|
cut, T3-01), same precedent as V3's four marketplace FK cuts:
|
||||||
|
* posts.generation_job_id -> creation.generation_jobs(id) ON DELETE SET NULL
|
||||||
|
(creation schema belongs to M4; the M4 migration that creates the
|
||||||
|
creation schema re-adds this constraint — M4 补回)
|
||||||
|
* posts.region_id -> platform.regions(id) ON DELETE SET NULL
|
||||||
|
(the region system belongs to M5 per ADR-018's scope cut; the M5
|
||||||
|
region-system migration re-adds this constraint — M5 补回)
|
||||||
|
Both columns are kept as bare nullable uuid and their indexes
|
||||||
|
(ix_posts_generation_job, ix_posts_region, ix_posts_region_feed) are
|
||||||
|
created as modeled.
|
||||||
|
- FKs to identity.users, pet_health.pets and media.assets are kept as-is:
|
||||||
|
all three schemas exist since V1/V3 (same shared-database precedent).
|
||||||
|
- Primary keys keep DEFAULT gen_random_uuid() as modeled (V1/V3 同规);
|
||||||
|
application code supplies explicit UUIDv7 ids, the default is a fallback.
|
||||||
|
- updated_at triggers on posts/comments reuse platform.set_updated_at()
|
||||||
|
created in V1.
|
||||||
|
- Structure only; no seed data.
|
||||||
|
- Never edit this file after release; subsequent changes go into V6+.
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE SCHEMA community;
|
||||||
|
|
||||||
|
COMMENT ON SCHEMA community IS 'Posts, comments, reactions, follows and topics';
|
||||||
|
|
||||||
|
CREATE TABLE community.posts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
|
||||||
|
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL,
|
||||||
|
-- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回)
|
||||||
|
generation_job_id uuid,
|
||||||
|
category varchar(24) NOT NULL DEFAULT 'general',
|
||||||
|
title varchar(120),
|
||||||
|
content text NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||||
|
visibility varchar(16) NOT NULL DEFAULT 'public',
|
||||||
|
-- region_id: bare nullable uuid, FK to platform.regions stripped (M5 补回)
|
||||||
|
region_id uuid,
|
||||||
|
location_text_snapshot varchar(128),
|
||||||
|
like_count bigint NOT NULL DEFAULT 0,
|
||||||
|
comment_count bigint NOT NULL DEFAULT 0,
|
||||||
|
bookmark_count bigint NOT NULL DEFAULT 0,
|
||||||
|
idempotency_key varchar(128),
|
||||||
|
request_hash bytea,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
published_at timestamptz,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
version integer NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uq_posts_author_idempotency UNIQUE (author_user_id, idempotency_key),
|
||||||
|
CONSTRAINT ck_posts_category CHECK (category IN ('general', 'help', 'ai_creation')),
|
||||||
|
CONSTRAINT ck_posts_title CHECK (title IS NULL OR char_length(btrim(title)) BETWEEN 1 AND 120),
|
||||||
|
CONSTRAINT ck_posts_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 10000),
|
||||||
|
CONSTRAINT ck_posts_status CHECK (status IN ('draft', 'published', 'hidden', 'archived')),
|
||||||
|
CONSTRAINT ck_posts_visibility CHECK (visibility IN ('public', 'followers', 'private')),
|
||||||
|
CONSTRAINT ck_posts_publish_state CHECK (
|
||||||
|
(status = 'published' AND published_at IS NOT NULL AND deleted_at IS NULL)
|
||||||
|
OR status <> 'published'
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_posts_counts CHECK (like_count >= 0 AND comment_count >= 0 AND bookmark_count >= 0),
|
||||||
|
CONSTRAINT ck_posts_idempotency CHECK (
|
||||||
|
(idempotency_key IS NULL AND request_hash IS NULL)
|
||||||
|
OR (
|
||||||
|
idempotency_key = btrim(idempotency_key)
|
||||||
|
AND char_length(idempotency_key) BETWEEN 1 AND 128
|
||||||
|
AND octet_length(request_hash) = 32
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_posts_version CHECK (version >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_posts_feed
|
||||||
|
ON community.posts (published_at DESC, id DESC)
|
||||||
|
WHERE status = 'published' AND visibility = 'public';
|
||||||
|
CREATE INDEX ix_posts_author_created
|
||||||
|
ON community.posts (author_user_id, created_at DESC, id DESC);
|
||||||
|
CREATE INDEX ix_posts_pet_created ON community.posts (pet_id, created_at DESC);
|
||||||
|
CREATE INDEX ix_posts_generation_job ON community.posts (generation_job_id);
|
||||||
|
CREATE INDEX ix_posts_region_feed
|
||||||
|
ON community.posts (region_id, published_at DESC, id DESC)
|
||||||
|
WHERE status = 'published' AND visibility = 'public';
|
||||||
|
CREATE INDEX ix_posts_region ON community.posts (region_id);
|
||||||
|
CREATE INDEX ix_posts_content_trgm
|
||||||
|
ON community.posts USING gin (content gin_trgm_ops)
|
||||||
|
WHERE status = 'published';
|
||||||
|
|
||||||
|
CREATE TABLE community.post_media (
|
||||||
|
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||||
|
position smallint NOT NULL,
|
||||||
|
asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT,
|
||||||
|
is_cover boolean NOT NULL DEFAULT false,
|
||||||
|
caption varchar(300),
|
||||||
|
PRIMARY KEY (post_id, position),
|
||||||
|
UNIQUE (post_id, asset_id),
|
||||||
|
CONSTRAINT ck_post_media_position CHECK (position >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_post_media_asset ON community.post_media (asset_id);
|
||||||
|
CREATE UNIQUE INDEX uq_post_media_cover
|
||||||
|
ON community.post_media (post_id)
|
||||||
|
WHERE is_cover;
|
||||||
|
|
||||||
|
-- Comments are deliberately one flat level. reply_to_user_id supports @ replies
|
||||||
|
-- without parent_comment_id, recursive queries, or unbounded comment nesting.
|
||||||
|
CREATE TABLE community.comments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||||
|
author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
|
||||||
|
reply_to_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL,
|
||||||
|
content varchar(2000) NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'visible',
|
||||||
|
client_request_id varchar(128),
|
||||||
|
request_hash bytea,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
UNIQUE (author_user_id, client_request_id),
|
||||||
|
CONSTRAINT ck_comments_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 2000),
|
||||||
|
CONSTRAINT ck_comments_status CHECK (status IN ('visible', 'hidden', 'deleted')),
|
||||||
|
CONSTRAINT ck_comments_idempotency CHECK (
|
||||||
|
(client_request_id IS NULL AND request_hash IS NULL)
|
||||||
|
OR (
|
||||||
|
client_request_id = btrim(client_request_id)
|
||||||
|
AND char_length(client_request_id) BETWEEN 1 AND 128
|
||||||
|
AND octet_length(request_hash) = 32
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_comments_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_comments_post_created
|
||||||
|
ON community.comments (post_id, created_at DESC, id DESC);
|
||||||
|
CREATE INDEX ix_comments_author_created
|
||||||
|
ON community.comments (author_user_id, created_at DESC, id DESC);
|
||||||
|
CREATE INDEX ix_comments_reply_user
|
||||||
|
ON community.comments (reply_to_user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE community.post_likes (
|
||||||
|
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_post_likes_user_created
|
||||||
|
ON community.post_likes (user_id, created_at DESC, post_id);
|
||||||
|
|
||||||
|
CREATE TABLE community.post_bookmarks (
|
||||||
|
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_post_bookmarks_user_created
|
||||||
|
ON community.post_bookmarks (user_id, created_at DESC, post_id);
|
||||||
|
|
||||||
|
CREATE TABLE community.user_follows (
|
||||||
|
follower_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||||
|
followee_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (follower_user_id, followee_user_id),
|
||||||
|
CONSTRAINT ck_user_follows_self CHECK (follower_user_id <> followee_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_user_follows_followee
|
||||||
|
ON community.user_follows (followee_user_id, created_at DESC, follower_user_id);
|
||||||
|
|
||||||
|
CREATE TABLE community.topics (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name citext NOT NULL UNIQUE,
|
||||||
|
description varchar(300),
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_topics_name CHECK (name::text = btrim(name::text) AND char_length(name::text) BETWEEN 1 AND 32),
|
||||||
|
CONSTRAINT ck_topics_status CHECK (status IN ('active', 'hidden'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE community.post_topics (
|
||||||
|
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
|
||||||
|
topic_id uuid NOT NULL REFERENCES community.topics(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (post_id, topic_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_post_topics_topic ON community.post_topics (topic_id, post_id);
|
||||||
|
|
||||||
|
-- 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_posts_updated_at BEFORE UPDATE ON community.posts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||||
|
CREATE TRIGGER trg_comments_updated_at BEFORE UPDATE ON community.comments
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
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 V5 (community schema baseline) applies cleanly on a postgres:18
|
||||||
|
* container after V1..V4 and produces the expected structure. Validates the
|
||||||
|
* mandatory cross-schema FK cuts (ADR-017/ADR-018, T3-01):
|
||||||
|
* generation_job_id (creation belongs to M4) and region_id (regions belong
|
||||||
|
* to the M5 region system) exist as bare nullable uuid columns without FKs,
|
||||||
|
* and the pg_trgm extension missing from V1 is now installed.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class CommunityMigrationIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5CreatesCommunitySchema() {
|
||||||
|
String exists = jdbcClient.sql(
|
||||||
|
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'community'")
|
||||||
|
.query(String.class)
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
assertThat(exists).isEqualTo("community");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5CreatesAllCommunityTables() {
|
||||||
|
int count = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||||
|
"WHERE table_schema = 'community' AND table_type = 'BASE TABLE'")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
// 8 tables: posts, post_media, comments, post_likes, post_bookmarks,
|
||||||
|
// user_follows, topics, post_topics (topics built per target model
|
||||||
|
// even though the topic feature is cut from the M3 MVP, ADR-018).
|
||||||
|
assertThat(count).isEqualTo(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5EnablesPgTrgmAndBuildsContentTrgmIndex() {
|
||||||
|
int extension = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_trgm'")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(extension).isEqualTo(1);
|
||||||
|
|
||||||
|
int index = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM pg_indexes " +
|
||||||
|
"WHERE schemaname = 'community' AND tablename = 'posts' " +
|
||||||
|
"AND indexname = 'ix_posts_content_trgm'")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(index).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5PostsColumnsExistWithoutCreationAndRegionFKs() {
|
||||||
|
// generation_job_id and region_id columns exist (bare nullable uuid)
|
||||||
|
int colCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||||
|
"AND column_name IN ('generation_job_id', 'region_id') " +
|
||||||
|
"AND udt_name = 'uuid' AND is_nullable = 'YES'")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(colCount).isEqualTo(2);
|
||||||
|
|
||||||
|
// No FK constraints targeting creation.generation_jobs (M4) or
|
||||||
|
// platform.regions (M5 region system)
|
||||||
|
int fkCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
||||||
|
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||||
|
"AND constraint_type = 'FOREIGN KEY' " +
|
||||||
|
"AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(fkCount).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5PostsKeepsInSchemaAndV1V3FKs() {
|
||||||
|
// author_user_id -> identity.users and pet_id -> pet_health.pets stay,
|
||||||
|
// both target schemas exist since V1/V3.
|
||||||
|
int fkCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(DISTINCT ccu.table_schema || '.' || ccu.table_name) " +
|
||||||
|
"FROM information_schema.table_constraints tc " +
|
||||||
|
"JOIN information_schema.constraint_column_usage ccu " +
|
||||||
|
" ON tc.constraint_name = ccu.constraint_name " +
|
||||||
|
" AND tc.constraint_schema = ccu.constraint_schema " +
|
||||||
|
"WHERE tc.table_schema = 'community' AND tc.table_name = 'posts' " +
|
||||||
|
"AND tc.constraint_type = 'FOREIGN KEY' " +
|
||||||
|
"AND ccu.table_schema || '.' || ccu.table_name " +
|
||||||
|
" IN ('identity.users', 'pet_health.pets')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(fkCount).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5StructureSpotChecks() {
|
||||||
|
// topics.name is citext (case-insensitive unique topic names)
|
||||||
|
String nameType = jdbcClient.sql(
|
||||||
|
"SELECT udt_name FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'community' AND table_name = 'topics' " +
|
||||||
|
"AND column_name = 'name'")
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(nameType).isEqualTo("citext");
|
||||||
|
|
||||||
|
String versionDefault = jdbcClient.sql(
|
||||||
|
"SELECT column_default FROM information_schema.columns " +
|
||||||
|
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||||
|
"AND column_name = 'version'")
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(versionDefault).isEqualTo("0");
|
||||||
|
|
||||||
|
// partial unique cover index on post_media
|
||||||
|
int coverIndex = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(*) FROM pg_indexes " +
|
||||||
|
"WHERE schemaname = 'community' AND tablename = 'post_media' " +
|
||||||
|
"AND indexname = 'uq_post_media_cover'")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(coverIndex).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5TriggersAreCreated() {
|
||||||
|
// The two updated_at triggers on posts and comments
|
||||||
|
int triggerCount = jdbcClient.sql(
|
||||||
|
"SELECT COUNT(DISTINCT trigger_name) FROM information_schema.triggers " +
|
||||||
|
"WHERE trigger_schema = 'community' " +
|
||||||
|
"AND trigger_name IN ('trg_posts_updated_at', 'trg_comments_updated_at')")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(triggerCount).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v5TopicsHaveNoSeedRows() {
|
||||||
|
// ADR-018: no topic seed data enters the production migration chain.
|
||||||
|
int rows = jdbcClient.sql("SELECT COUNT(*) FROM community.topics")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(rows).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user