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,43 @@
package com.patbond.patbond.auth.config;
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 feign.Response;
import feign.Util;
import feign.codec.ErrorDecoder;
import java.io.IOException;
/**
* Translates non-2xx replies from downstream services back into
* {@link BusinessException}, so the business code, message and HTTP status
* the user service chose reach the client unchanged instead of collapsing
* into 500/400 (audit issue M1). Anything that does not carry a readable
* {@code {code, message}} envelope is reported as DOWNSTREAM_UNAVAILABLE.
*/
public class ApiErrorDecoder implements ErrorDecoder {
private final ObjectMapper objectMapper;
public ApiErrorDecoder(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Exception decode(String methodKey, Response response) {
try {
if (response.body() != null) {
String body = Util.toString(response.body().asReader(Util.UTF_8));
ApiResponse<?> envelope = objectMapper.readValue(body, ApiResponse.class);
if (envelope != null && envelope.getCode() != null && envelope.getCode() != 0) {
return new BusinessException(envelope.getCode(), response.status(), envelope.getMessage());
}
}
} catch (IOException | RuntimeException ignored) {
// Not a Patbond envelope; fall through to the generic error below.
}
return new BusinessException(ErrorCode.DOWNSTREAM_UNAVAILABLE);
}
}
@@ -0,0 +1,15 @@
package com.patbond.patbond.auth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeignConfig {
@Bean
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
return new ApiErrorDecoder(objectMapper);
}
}
@@ -1,18 +1,19 @@
package com.patbond.patbond.auth.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public class AuthTokenResponse {
private String tokenType;
private String accessToken;
private LocalDateTime expiresAt;
private Long userId;
private UUID userId;
private String username;
private String nickname;
public AuthTokenResponse(String tokenType, String accessToken, LocalDateTime expiresAt,
Long userId, String username, String nickname) {
UUID userId, String username, String nickname) {
this.tokenType = tokenType;
this.accessToken = accessToken;
this.expiresAt = expiresAt;
@@ -33,7 +34,7 @@ public class AuthTokenResponse {
return expiresAt;
}
public Long getUserId() {
public UUID getUserId() {
return userId;
}
@@ -1,6 +1,7 @@
package com.patbond.patbond.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public class RegisterRequest {
@@ -16,7 +17,8 @@ public class RegisterRequest {
@Size(max = 32, message = "昵称长度不能超过32位")
private String nickname;
@Size(max = 20, message = "手机号长度不能超过20位")
// Aligned with the identity.users ck_users_phone CHECK constraint (E.164).
@Pattern(regexp = "^\\+[1-9][0-9]{7,14}$", message = "手机号必须为 E.164 格式,例如 +8613800138000")
private String phone;
public String getUsername() {
@@ -4,14 +4,14 @@ import com.patbond.patbond.auth.client.UserClient;
import com.patbond.patbond.auth.dto.AuthTokenResponse;
import com.patbond.patbond.auth.dto.LoginRequest;
import com.patbond.patbond.auth.dto.RegisterRequest;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.user.CreateUserRequest;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.common.user.VerifyPasswordRequest;
import com.patbond.patbond.common.user.VerifyPasswordResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.UUID;
@@ -44,7 +44,7 @@ public class AuthService {
return buildToken(user.getUserId(), user.getUsername(), user.getNickname());
}
private AuthTokenResponse buildToken(Long userId, String username, String nickname) {
private AuthTokenResponse buildToken(UUID userId, String username, String nickname) {
return new AuthTokenResponse(
"Bearer",
UUID.randomUUID().toString().replace("-", ""),
@@ -55,10 +55,15 @@ public class AuthService {
);
}
/**
* Downstream business failures arrive as BusinessException via the Feign
* ErrorDecoder and never reach this method; this only guards against a
* 2xx reply with a malformed envelope.
*/
private <T> T requireData(ApiResponse<T> response, String defaultMessage) {
if (response == null || !response.isSuccess() || response.getData() == null) {
String message = response == null || response.getMessage() == null ? defaultMessage : response.getMessage();
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, message);
}
return response.getData();
}
@@ -0,0 +1,70 @@
package com.patbond.patbond.auth.web;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import feign.FeignException;
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;
/**
* Auth-side error contract. BusinessException covers both local failures and
* downstream errors re-raised by {@code ApiErrorDecoder}; a raw FeignException
* only remains for transport-level failures (connect refused, timeout), which
* are reported as 503 instead of leaking a stack trace as 500.
*/
@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(FeignException.class)
public ResponseEntity<ApiResponse<Void>> handleFeign(FeignException e) {
log.error("User service call failed", e);
return failure(ErrorCode.DOWNSTREAM_UNAVAILABLE, ErrorCode.DOWNSTREAM_UNAVAILABLE.getDefaultMessage());
}
@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,58 @@
package com.patbond.patbond.auth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.common.error.BusinessException;
import feign.Request;
import feign.Response;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class ApiErrorDecoderTest {
private final ApiErrorDecoder decoder = new ApiErrorDecoder(new ObjectMapper());
private static Response response(int status, String body) {
Request request = Request.create(Request.HttpMethod.POST, "/internal/users",
Map.of(), null, StandardCharsets.UTF_8, null);
Response.Builder builder = Response.builder().status(status).request(request);
if (body != null) {
builder.body(body, StandardCharsets.UTF_8);
}
return builder.build();
}
@Test
void passesThroughDownstreamBusinessCodeStatusAndMessage() {
Exception decoded = decoder.decode("UserClient#createUser",
response(409, "{\"code\":40900,\"message\":\"用户名已存在\",\"data\":null}"));
assertThat(decoded).isInstanceOfSatisfying(BusinessException.class, e -> {
assertThat(e.getCode()).isEqualTo(40900);
assertThat(e.getHttpStatus()).isEqualTo(409);
assertThat(e.getMessage()).isEqualTo("用户名已存在");
});
}
@Test
void fallsBackToDownstreamUnavailableForNonEnvelopeBody() {
Exception decoded = decoder.decode("UserClient#createUser",
response(500, "<html>gateway error</html>"));
assertThat(decoded).isInstanceOfSatisfying(BusinessException.class, e -> {
assertThat(e.getCode()).isEqualTo(50300);
assertThat(e.getHttpStatus()).isEqualTo(503);
});
}
@Test
void fallsBackToDownstreamUnavailableForEmptyBody() {
Exception decoded = decoder.decode("UserClient#verifyPassword", response(500, null));
assertThat(decoded).isInstanceOfSatisfying(BusinessException.class,
e -> assertThat(e.getCode()).isEqualTo(50300));
}
}
@@ -1,6 +1,8 @@
package com.patbond.patbond.auth.controller;
import com.patbond.patbond.auth.client.UserClient;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.common.user.VerifyPasswordResponse;
@@ -16,10 +18,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -28,10 +30,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* MockMvc tests for the auth endpoints. The Feign UserClient is replaced with
* a Mockito mock so no user-service process is required. Assertions follow the
* CURRENT behaviour of the in-memory prototype (opaque token, business
* failures folded to 400, FeignException not yet translated) — recorded here
* as the baseline the upcoming error-contract work will change deliberately.
* a Mockito mock so no user-service process is required. Downstream business
* failures are simulated as the BusinessException the ApiErrorDecoder raises,
* so these tests pin the FIXED error contract: 409/401/400 pass through to
* the client with stable business codes instead of collapsing to 400/500
* (audit issue M1); transport-level Feign failures answer 503.
*/
@SpringBootTest
@AutoConfigureMockMvc
@@ -43,8 +46,10 @@ class AuthControllerTest {
@MockitoBean
private UserClient userClient;
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
private static final String REGISTER_BODY = """
{"username":"alice","password":"secret123","nickname":"Alice","phone":"13800000000"}
{"username":"alice","password":"secret123","nickname":"Alice","phone":"+8613800138000"}
""";
private static final String LOGIN_BODY = """
{"username":"alice","password":"secret123"}
@@ -52,7 +57,7 @@ class AuthControllerTest {
@Test
void registerReturnsTokenWhenUserServiceSucceeds() throws Exception {
UserProfile profile = new UserProfile(1L, "alice", "Alice", null, LocalDateTime.now());
UserProfile profile = new UserProfile(USER_ID, "alice", "Alice", null, OffsetDateTime.now());
when(userClient.createUser(any())).thenReturn(ApiResponse.success(profile));
mockMvc.perform(post("/auth/register")
@@ -62,18 +67,21 @@ class AuthControllerTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andExpect(jsonPath("$.data.userId").value(1))
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
.andExpect(jsonPath("$.data.username").value("alice"));
}
@Test
void registerReturnsBadRequestWhenUserServiceReportsFailure() throws Exception {
when(userClient.createUser(any())).thenReturn(ApiResponse.failure(409, "用户名已存在"));
void registerPropagatesDuplicateUsernameAsConflict() throws Exception {
when(userClient.createUser(any()))
.thenThrow(new BusinessException(ErrorCode.USERNAME_EXISTS));
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(REGISTER_BODY))
.andExpect(status().isBadRequest());
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40900))
.andExpect(jsonPath("$.message").value("用户名已存在"));
}
@Test
@@ -81,13 +89,25 @@ class AuthControllerTest {
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
.andExpect(status().isBadRequest());
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void registerRejectsNonE164Phone() throws Exception {
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"secret123","phone":"13800138000"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void loginReturnsTokenWhenPasswordVerified() throws Exception {
when(userClient.verifyPassword(any()))
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(1L, "alice", "Alice")));
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", "Alice")));
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
@@ -95,17 +115,20 @@ class AuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andExpect(jsonPath("$.data.userId").value(1));
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()));
}
@Test
void loginReturnsBadRequestWhenVerificationReportsFailure() throws Exception {
when(userClient.verifyPassword(any())).thenReturn(ApiResponse.failure(401, "用户名或密码错误"));
void loginPropagatesWrongPasswordAsUnauthorized() throws Exception {
when(userClient.verifyPassword(any()))
.thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS));
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(LOGIN_BODY))
.andExpect(status().isBadRequest());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40100))
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
}
@Test
@@ -113,22 +136,22 @@ class AuthControllerTest {
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"\",\"password\":\"\"}"))
.andExpect(status().isBadRequest());
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void loginPropagatesFeignExceptionUnhandled() throws Exception {
void loginAnswersServiceUnavailableOnTransportLevelFeignFailure() throws Exception {
Request request = Request.create(Request.HttpMethod.POST, "/internal/users/verify-password",
Map.of(), null, StandardCharsets.UTF_8, null);
FeignException unauthorized = FeignException.errorStatus("UserClient#verifyPassword",
Response.builder().status(401).request(request).build());
when(userClient.verifyPassword(any())).thenThrow(unauthorized);
FeignException transportFailure = FeignException.errorStatus("UserClient#verifyPassword",
Response.builder().status(502).request(request).build());
when(userClient.verifyPassword(any())).thenThrow(transportFailure);
// Current baseline: a downstream 401 raised as FeignException is not
// translated, so it escapes the MVC layer (a real deployment answers 500).
assertThatThrownBy(() -> mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(LOGIN_BODY)))
.hasCauseInstanceOf(FeignException.class);
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(LOGIN_BODY))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value(50300));
}
}