feat: 用户 UUID 持久化与统一异常契约(Flyway baseline / UUIDv7 / 错误码透传)

- Flyway V1 baseline:identity 全部 5 表 + media.assets + platform 最小硬依赖(set_updated_at/regions),无 fixture 凭据;dev 种子独立为 afterMigrate 回调且默认不执行
- patbond-user 迁移到 PostgreSQL:UUIDv7 主键、JdbcClient 仓储、bcrypt 密码、唯一性依赖 DB 约束翻译为 409、E.164 手机号校验对齐 ck_users_phone
- 统一异常契约:common 新增 ErrorCode/BusinessException,两服务 GlobalExceptionHandler;auth 经 ApiErrorDecoder 原码透传下游错误,修复状态码折叠(审计 M1)
- 门禁:JAVA_HOME=jdk17 ./mvnw clean test,37 个测试 0 失败(含 Testcontainers postgres:16 集成测试),BUILD SUCCESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:34:48 +08:00
parent c7ddaecb76
commit bd20adc700
28 changed files with 1266 additions and 159 deletions
@@ -0,0 +1,24 @@
package com.patbond.patbond.user;
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:16 (the production
* target version) wired into the Spring context via @ServiceConnection.
* Flyway runs the real migrations against it on context startup, so every
* @SpringBootTest in this module exercises the V1 baseline on a clean
* database. No local PostgreSQL installation is used or required.
*/
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"));
}
}
@@ -2,13 +2,15 @@ package com.patbond.patbond.user;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class UserApplicationTests {
@Test
void contextLoads() {
// Verifies the user service starts with the committed application.yml
// and no external infrastructure (post ADR-002 Nacos removal).
// Verifies the user service starts against a clean PostgreSQL 16
// (Testcontainers) with the Flyway baseline applied on boot.
}
}
@@ -1,45 +1,63 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.hamcrest.Matchers.matchesPattern;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* MockMvc tests against the current in-memory UserService implementation.
* The service is a stateful singleton within the shared test context, so each
* test uses its own username.
* MockMvc tests against the PostgreSQL-backed UserService (Testcontainers).
* The database lives for the whole test context, so each test uses its own
* username/phone to stay independent.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class UserControllerTest {
private static final String UUID_PATTERN =
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
@Autowired
private MockMvc mockMvc;
private static String createUserBody(String username) {
return """
{"username":"%s","password":"secret123","nickname":"Nick","phone":"13800000000"}
{"username":"%s","password":"secret123","nickname":"Nick"}
""".formatted(username);
}
private static String createUserBody(String username, String phone) {
return """
{"username":"%s","password":"secret123","nickname":"Nick","phone":"%s"}
""".formatted(username, phone);
}
@Test
void createUserReturnsProfileWithoutPassword() throws Exception {
void createUserReturnsUuidProfileWithoutPassword() throws Exception {
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("alice_create")))
.content(createUserBody("alice_create", "+8613800000101")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNumber())
.andExpect(jsonPath("$.data.id", matchesPattern(UUID_PATTERN)))
.andExpect(jsonPath("$.data.username").value("alice_create"))
.andExpect(jsonPath("$.data.nickname").value("Nick"))
.andExpect(jsonPath("$.data.phone").value("+8613800000101"))
.andExpect(jsonPath("$.data.createdAt").isNotEmpty())
.andExpect(jsonPath("$.data.password").doesNotExist());
}
@@ -53,7 +71,37 @@ class UserControllerTest {
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("bob_dup")))
.andExpect(status().isConflict());
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40900));
}
@Test
void duplicateUsernameCheckIsCaseInsensitive() throws Exception {
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("casey_case")))
.andExpect(status().isOk());
// identity.users.username is citext: uniqueness ignores case.
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("CASEY_CASE")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40900));
}
@Test
void createUserWithDuplicatePhoneReturnsConflict() throws Exception {
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("pia_phone1", "+8613800000202")))
.andExpect(status().isOk());
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("pia_phone2", "+8613800000202")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40901));
}
@Test
@@ -61,7 +109,19 @@ class UserControllerTest {
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
.andExpect(status().isBadRequest());
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void createUserWithNonE164PhoneReturnsBadRequest() throws Exception {
// 13800000000 passed the old length-only rule but violates the DB
// ck_users_phone CHECK; the DTO now rejects it up front (issue B4).
mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("nina_badphone", "13800000000")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
@@ -76,7 +136,7 @@ class UserControllerTest {
.content("{\"username\":\"carol_verify\",\"password\":\"secret123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").isNumber())
.andExpect(jsonPath("$.data.userId", matchesPattern(UUID_PATTERN)))
.andExpect(jsonPath("$.data.username").value("carol_verify"));
}
@@ -90,7 +150,8 @@ class UserControllerTest {
mockMvc.perform(post("/internal/users/verify-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"dave_wrongpw\",\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40100));
}
@Test
@@ -98,27 +159,37 @@ class UserControllerTest {
mockMvc.perform(post("/internal/users/verify-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"no_such_user\",\"password\":\"whatever1\"}"))
.andExpect(status().isUnauthorized());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40100));
}
@Test
void getByIdReturnsProfileForExistingUser() throws Exception {
String location = mockMvc.perform(post("/internal/users")
String body = mockMvc.perform(post("/internal/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createUserBody("erin_getbyid")))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
long id = Long.parseLong(location.replaceAll(".*\"id\":(\\d+).*", "$1"));
String id = JsonPath.read(body, "$.data.id");
mockMvc.perform(get("/internal/users/{id}", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(id))
.andExpect(jsonPath("$.data.username").value("erin_getbyid"));
}
@Test
void getByIdForUnknownUserReturnsNotFound() throws Exception {
mockMvc.perform(get("/internal/users/{id}", 999999L))
.andExpect(status().isNotFound());
mockMvc.perform(get("/internal/users/{id}", UUID.randomUUID()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
}
@Test
void getByIdWithMalformedUuidReturnsBadRequest() throws Exception {
mockMvc.perform(get("/internal/users/{id}", "not-a-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
@@ -133,6 +204,7 @@ class UserControllerTest {
.andExpect(jsonPath("$.data.username").value("frank_byname"));
mockMvc.perform(get("/internal/users/by-username/{username}", "ghost_user"))
.andExpect(status().isNotFound());
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
}
}
@@ -0,0 +1,129 @@
package com.patbond.patbond.user.persistence;
import com.patbond.patbond.common.user.CreateUserRequest;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.service.UserService;
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 org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Verifies the Flyway V1 baseline and the real persistence semantics against
* a clean postgres:16 container: migrations apply, constraints hold, and a
* registered user is durably stored in PostgreSQL (readable over a fresh raw
* JDBC connection, i.e. independent of any application-process memory — the
* restart-survival semantics that killed the old ConcurrentHashMap storage).
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class UserPersistenceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private JdbcClient jdbcClient;
@Autowired
private PostgreSQLContainer<?> postgres;
@Test
void flywayBaselineAppliedOnCleanPostgres16() {
Integer applied = jdbcClient.sql("""
SELECT count(*) FROM flyway_schema_history
WHERE success AND version = '1'
""")
.query(Integer.class)
.single();
assertThat(applied).isEqualTo(1);
List<String> tables = jdbcClient.sql("""
SELECT table_schema || '.' || table_name
FROM information_schema.tables
WHERE table_schema IN ('identity', 'media', 'platform')
ORDER BY 1
""")
.query(String.class)
.list();
assertThat(tables).contains(
"identity.users",
"identity.user_credentials",
"identity.auth_sessions",
"identity.user_addresses",
"identity.user_preferences",
"media.assets",
"platform.regions");
}
@Test
void devSeedIsNotLoadedByDefault() {
Integer regions = jdbcClient.sql("SELECT count(*) FROM platform.regions")
.query(Integer.class)
.single();
assertThat(regions).isZero();
}
@Test
void registeredUserIsDurablyStoredWithBcryptHash() throws SQLException {
UserProfile profile = userService.createUser(
new CreateUserRequest("persist_user", "secret123", "小柴", "+8613800000301"));
// Read back over a brand-new raw JDBC connection: what we see here is
// what any restarted process would see.
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
PreparedStatement statement = connection.prepareStatement("""
SELECT u.username::text AS username, u.phone_e164, c.password_hash
FROM identity.users u
JOIN identity.user_credentials c ON c.user_id = u.id
WHERE u.id = ?
""")) {
statement.setObject(1, profile.getId());
try (ResultSet rs = statement.executeQuery()) {
assertThat(rs.next()).isTrue();
assertThat(rs.getString("username")).isEqualTo("persist_user");
assertThat(rs.getString("phone_e164")).isEqualTo("+8613800000301");
String hash = rs.getString("password_hash");
assertThat(hash).startsWith("$2").doesNotContain("secret123");
}
}
}
@Test
void userIdsAreTimeOrderedUuidV7() {
UserProfile first = userService.createUser(
new CreateUserRequest("uuid7_first", "secret123", null, null));
UserProfile second = userService.createUser(
new CreateUserRequest("uuid7_second", "secret123", null, null));
assertThat(first.getId().version()).isEqualTo(7);
assertThat(second.getId().version()).isEqualTo(7);
assertThat(first.getId().variant()).isEqualTo(2);
}
@Test
void databaseRejectsNonE164PhoneEvenIfValidationWereBypassed() {
assertThatThrownBy(() -> jdbcClient.sql("""
INSERT INTO identity.users (id, username, phone_e164)
VALUES (:id, 'bypass_phone', '13800000000')
""")
.param("id", UUID.randomUUID())
.update())
.hasMessageContaining("ck_users_phone");
}
}
@@ -0,0 +1,37 @@
package com.patbond.patbond.user.support;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class UuidV7Test {
@Test
void generatesRfc9562Version7Variant2Values() {
UUID uuid = UuidV7.generate();
assertThat(uuid.version()).isEqualTo(7);
assertThat(uuid.variant()).isEqualTo(2);
}
@Test
void embedsCurrentUnixMillisecondTimestamp() {
long before = System.currentTimeMillis();
UUID uuid = UuidV7.generate();
long after = System.currentTimeMillis();
long embedded = uuid.getMostSignificantBits() >>> 16;
assertThat(embedded).isBetween(before, after);
}
@Test
void generatesDistinctValues() {
Set<UUID> seen = new HashSet<>();
for (int i = 0; i < 10_000; i++) {
assertThat(seen.add(UuidV7.generate())).isTrue();
}
}
}