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:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
+89
-17
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+129
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user