diff --git a/Readme.md b/Readme.md index f333b04..e1516d9 100644 --- a/Readme.md +++ b/Readme.md @@ -13,6 +13,7 @@ Patbond API is a Spring Boot multi-module backend. - Java 17 (build baseline; use JDK 17 for release builds) - Spring Boot 3.5.16 - Spring Cloud 2025.0.3 (OpenFeign only) +- PostgreSQL 16 + Flyway (patbond-user owns the `identity`/`media` schemas) - Maven (use the committed Maven Wrapper `./mvnw`) ## Build and Test @@ -21,6 +22,10 @@ Patbond API is a Spring Boot multi-module backend. ./mvnw clean test ``` +Integration tests start a disposable `postgres:16` via Testcontainers, so a +running Docker daemon is required (no local PostgreSQL installation or +credentials are needed). + If your default JDK is not 17, point `JAVA_HOME` at a JDK 17 installation first, e.g.: ```bash @@ -29,6 +34,12 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test ## Run +Running `patbond-user` requires a reachable PostgreSQL 16 database; Flyway +applies the versioned migrations in +`patbond-user/src/main/resources/db/migration` automatically on startup. +Development seed data (`db/dev`, regions reference rows) is opt-in via a dev +profile — see `application.yml.sample`. + Each service ships a committed `application.yml.sample`; the real `application.yml` is git-ignored. First copy the samples (defaults work locally, overrides via environment variables): @@ -66,6 +77,9 @@ curl -X POST http://127.0.0.1:8081/auth/register \ | `PATBOND_USER_PORT` | `8082` | patbond-user | | `PATBOND_AUTH_PORT` | `8081` | patbond-auth | | `PATBOND_USER_SERVICE_URL` | `http://127.0.0.1:8082` | patbond-auth (Feign target for patbond-user) | +| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user | +| `PATBOND_DB_USER` | `patbond` | patbond-user | +| `PATBOND_DB_PASSWORD` | `patbond` | patbond-user | Machine-specific values live in the git-ignored `application.yml` (copied from the committed `.sample`); never commit secrets to the samples. @@ -88,6 +102,9 @@ committed `.sample`); never commit secrets to the samples. - Get user by id: `GET /internal/users/{id}` - Get user by username: `GET /internal/users/by-username/{username}` -> Note: the user store is currently in-memory (prototype); data is lost on restart. -> Persistence (PostgreSQL + Flyway), verifiable JWT tokens, and `/internal` access -> control are planned in iteration 1 follow-up tasks. +> Note: user data is persisted in PostgreSQL (`identity.users` / +> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in +> the application). Errors follow the `{code, message, data}` envelope with +> stable business codes and matching HTTP statuses. Verifiable JWT tokens, +> refresh sessions, and `/internal` access control are planned in iteration 1 +> follow-up tasks. diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java new file mode 100644 index 0000000..359f513 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java @@ -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); + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java new file mode 100644 index 0000000..535605a --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java @@ -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); + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java index a9ca6df..0800cf7 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java @@ -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; } diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RegisterRequest.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RegisterRequest.java index 5ebe1b2..cbd2203 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RegisterRequest.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RegisterRequest.java @@ -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() { diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java index a2a1d37..4aec25f 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java @@ -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 requireData(ApiResponse 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(); } diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/web/GlobalExceptionHandler.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..09ee991 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/web/GlobalExceptionHandler.java @@ -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> handleBusiness(BusinessException e) { + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.failure(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(FeignException.class) + public ResponseEntity> handleFeign(FeignException e) { + log.error("User service call failed", e); + return failure(ErrorCode.DOWNSTREAM_UNAVAILABLE, ErrorCode.DOWNSTREAM_UNAVAILABLE.getDefaultMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleMalformedRequest(Exception e) { + return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("Unhandled exception", e); + return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage()); + } + + private static ResponseEntity> failure(ErrorCode errorCode, String message) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ApiResponse.failure(errorCode.getCode(), message)); + } +} diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/config/ApiErrorDecoderTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/config/ApiErrorDecoderTest.java new file mode 100644 index 0000000..0048d9d --- /dev/null +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/config/ApiErrorDecoderTest.java @@ -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, "gateway error")); + + 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)); + } +} diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java index e561b71..752f234 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java @@ -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)); } } diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/BusinessException.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/BusinessException.java new file mode 100644 index 0000000..eeb2d6c --- /dev/null +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/BusinessException.java @@ -0,0 +1,35 @@ +package com.patbond.patbond.common.error; + +/** + * Carries a business error code plus its HTTP status through the service + * layers. Also used on the auth side to re-raise errors decoded from a + * downstream {@code ApiResponse} body verbatim, which is why it stores raw + * ints instead of only an {@link ErrorCode} constant. + */ +public class BusinessException extends RuntimeException { + + private final int code; + private final int httpStatus; + + public BusinessException(ErrorCode errorCode) { + this(errorCode.getCode(), errorCode.getHttpStatus(), errorCode.getDefaultMessage()); + } + + public BusinessException(ErrorCode errorCode, String message) { + this(errorCode.getCode(), errorCode.getHttpStatus(), message); + } + + public BusinessException(int code, int httpStatus, String message) { + super(message); + this.code = code; + this.httpStatus = httpStatus; + } + + public int getCode() { + return code; + } + + public int getHttpStatus() { + return httpStatus; + } +} diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java new file mode 100644 index 0000000..8b7cecf --- /dev/null +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -0,0 +1,41 @@ +package com.patbond.patbond.common.error; + +/** + * Stable business error codes shared by all services (development-plan 6.1: + * errors must carry both a correct HTTP status and a stable business code). + * The numeric code is what clients switch on; the HTTP status is the + * transport-level view of the same failure. Codes are contract: never reuse + * or renumber a released value. + */ +public enum ErrorCode { + + VALIDATION_ERROR(40000, 400, "参数校验失败"), + INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"), + USER_NOT_FOUND(40400, 404, "用户不存在"), + USERNAME_EXISTS(40900, 409, "用户名已存在"), + PHONE_EXISTS(40901, 409, "手机号已被使用"), + INTERNAL_ERROR(50000, 500, "服务器内部错误"), + DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用"); + + private final int code; + private final int httpStatus; + private final String defaultMessage; + + ErrorCode(int code, int httpStatus, String defaultMessage) { + this.code = code; + this.httpStatus = httpStatus; + this.defaultMessage = defaultMessage; + } + + public int getCode() { + return code; + } + + public int getHttpStatus() { + return httpStatus; + } + + public String getDefaultMessage() { + return defaultMessage; + } +} diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/user/CreateUserRequest.java b/patbond-common/src/main/java/com/patbond/patbond/common/user/CreateUserRequest.java index c4a2aa8..295171d 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/user/CreateUserRequest.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/user/CreateUserRequest.java @@ -1,6 +1,7 @@ package com.patbond.patbond.common.user; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public class CreateUserRequest { @@ -16,7 +17,8 @@ public class CreateUserRequest { @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 CreateUserRequest() { diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/user/UserProfile.java b/patbond-common/src/main/java/com/patbond/patbond/common/user/UserProfile.java index ddc9c8a..8bc2bcc 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/user/UserProfile.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/user/UserProfile.java @@ -1,19 +1,20 @@ package com.patbond.patbond.common.user; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.UUID; public class UserProfile { - private Long id; + private UUID id; private String username; private String nickname; private String phone; - private LocalDateTime createdAt; + private OffsetDateTime createdAt; public UserProfile() { } - public UserProfile(Long id, String username, String nickname, String phone, LocalDateTime createdAt) { + public UserProfile(UUID id, String username, String nickname, String phone, OffsetDateTime createdAt) { this.id = id; this.username = username; this.nickname = nickname; @@ -21,11 +22,11 @@ public class UserProfile { this.createdAt = createdAt; } - public Long getId() { + public UUID getId() { return id; } - public void setId(Long id) { + public void setId(UUID id) { this.id = id; } @@ -53,11 +54,11 @@ public class UserProfile { this.phone = phone; } - public LocalDateTime getCreatedAt() { + public OffsetDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/user/VerifyPasswordResponse.java b/patbond-common/src/main/java/com/patbond/patbond/common/user/VerifyPasswordResponse.java index e204b3b..b4cd7a7 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/user/VerifyPasswordResponse.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/user/VerifyPasswordResponse.java @@ -1,25 +1,27 @@ package com.patbond.patbond.common.user; +import java.util.UUID; + public class VerifyPasswordResponse { - private Long userId; + private UUID userId; private String username; private String nickname; public VerifyPasswordResponse() { } - public VerifyPasswordResponse(Long userId, String username, String nickname) { + public VerifyPasswordResponse(UUID userId, String username, String nickname) { this.userId = userId; this.username = username; this.nickname = nickname; } - public Long getUserId() { + public UUID getUserId() { return userId; } - public void setUserId(Long userId) { + public void setUserId(UUID userId) { this.userId = userId; } diff --git a/patbond-user/pom.xml b/patbond-user/pom.xml index f16f993..e62c96a 100644 --- a/patbond-user/pom.xml +++ b/patbond-user/pom.xml @@ -35,11 +35,43 @@ org.springframework.security spring-security-crypto + + org.springframework.boot + spring-boot-starter-jdbc + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + org.springframework.boot spring-boot-starter-test test + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/controller/UserController.java b/patbond-user/src/main/java/com/patbond/patbond/user/controller/UserController.java index e6687ef..1f2533b 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/controller/UserController.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/controller/UserController.java @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @RequestMapping("/internal/users") public class UserController { @@ -35,7 +37,7 @@ public class UserController { } @GetMapping("/{id}") - public ApiResponse getById(@PathVariable Long id) { + public ApiResponse getById(@PathVariable UUID id) { return ApiResponse.success(userService.getById(id)); } diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java new file mode 100644 index 0000000..fcc245e --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java @@ -0,0 +1,103 @@ +package com.patbond.patbond.user.repository; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.UUID; + +/** + * JDBC access to identity.users / identity.user_credentials. Soft-deleted + * rows (deleted_at set) are invisible to every read. Uniqueness of username + * and phone is enforced by the database constraints; callers translate the + * resulting DuplicateKeyException. + */ +@Repository +public class UserRepository { + + /** Profile columns shared by all reads. */ + private static final String SELECT_PROFILE = """ + SELECT id, username::text AS username, nickname, phone_e164, created_at + FROM identity.users + WHERE deleted_at IS NULL + """; + + private final JdbcClient jdbcClient; + + public UserRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + public record UserRow(UUID id, String username, String nickname, String phone, OffsetDateTime createdAt) { + } + + public record AuthRow(UUID id, String username, String nickname, String passwordHash) { + } + + /** Inserts the user row; created_at/updated_at come from the DB defaults. */ + public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) { + return jdbcClient.sql(""" + INSERT INTO identity.users (id, username, nickname, phone_e164) + VALUES (:id, :username, :nickname, :phone) + RETURNING created_at + """) + .param("id", id) + .param("username", username) + .param("nickname", nickname) + .param("phone", phone) + .query(OffsetDateTime.class) + .single(); + } + + public void insertCredential(UUID userId, String passwordHash) { + jdbcClient.sql(""" + INSERT INTO identity.user_credentials (user_id, password_hash, hash_algorithm) + VALUES (:userId, :passwordHash, 'bcrypt') + """) + .param("userId", userId) + .param("passwordHash", passwordHash) + .update(); + } + + public Optional findById(UUID id) { + return jdbcClient.sql(SELECT_PROFILE + "AND id = :id") + .param("id", id) + .query((rs, rowNum) -> new UserRow( + rs.getObject("id", UUID.class), + rs.getString("username"), + rs.getString("nickname"), + rs.getString("phone_e164"), + rs.getObject("created_at", OffsetDateTime.class))) + .optional(); + } + + /** citext equality makes the lookup case-insensitive, matching the unique constraint. */ + public Optional findByUsername(String username) { + return jdbcClient.sql(SELECT_PROFILE + "AND username = :username") + .param("username", username) + .query((rs, rowNum) -> new UserRow( + rs.getObject("id", UUID.class), + rs.getString("username"), + rs.getString("nickname"), + rs.getString("phone_e164"), + rs.getObject("created_at", OffsetDateTime.class))) + .optional(); + } + + public Optional findAuthByUsername(String username) { + return jdbcClient.sql(""" + SELECT u.id, u.username::text AS username, u.nickname, c.password_hash + FROM identity.users u + JOIN identity.user_credentials c ON c.user_id = u.id + WHERE u.deleted_at IS NULL AND u.username = :username + """) + .param("username", username) + .query((rs, rowNum) -> new AuthRow( + rs.getObject("id", UUID.class), + rs.getString("username"), + rs.getString("nickname"), + rs.getString("password_hash"))) + .optional(); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java b/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java index 2bedd31..49129ad 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java @@ -1,120 +1,98 @@ package com.patbond.patbond.user.service; +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; 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 com.patbond.patbond.user.repository.UserRepository; +import com.patbond.patbond.user.support.UuidV7; +import org.springframework.dao.DuplicateKeyException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; +import java.time.OffsetDateTime; +import java.util.UUID; @Service public class UserService { - private final AtomicLong idGenerator = new AtomicLong(1); - private final Map usersById = new ConcurrentHashMap<>(); - private final Map usersByUsername = new ConcurrentHashMap<>(); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - public synchronized UserProfile createUser(CreateUserRequest request) { - String username = request.getUsername().trim(); - if (usersByUsername.containsKey(username)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "用户名已存在"); - } + /** + * Matched against when the username does not exist, so the response time + * of verifyPassword does not reveal whether an account exists. + */ + private final String unknownUserHash = passwordEncoder.encode(UUID.randomUUID().toString()); - UserRecord user = new UserRecord( - idGenerator.getAndIncrement(), - username, - passwordEncoder.encode(request.getPassword()), - normalizeBlank(request.getNickname()), - normalizeBlank(request.getPhone()), - LocalDateTime.now() - ); - usersById.put(user.getId(), user); - usersByUsername.put(user.getUsername(), user); - return toProfile(user); + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional + public UserProfile createUser(CreateUserRequest request) { + UUID id = UuidV7.generate(); + String username = request.getUsername().trim(); + String nickname = normalizeBlank(request.getNickname()); + String phone = normalizeBlank(request.getPhone()); + + OffsetDateTime createdAt; + try { + createdAt = userRepository.insertUser(id, username, nickname, phone); + userRepository.insertCredential(id, passwordEncoder.encode(request.getPassword())); + } catch (DuplicateKeyException e) { + throw translateDuplicate(e); + } + return new UserProfile(id, username, nickname, phone, createdAt); } public VerifyPasswordResponse verifyPassword(VerifyPasswordRequest request) { - UserRecord user = usersByUsername.get(request.getUsername().trim()); - if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误"); + UserRepository.AuthRow auth = userRepository.findAuthByUsername(request.getUsername().trim()) + .orElse(null); + String storedHash = auth == null ? unknownUserHash : auth.passwordHash(); + if (!passwordEncoder.matches(request.getPassword(), storedHash) || auth == null) { + throw new BusinessException(ErrorCode.INVALID_CREDENTIALS); } - return new VerifyPasswordResponse(user.getId(), user.getUsername(), user.getNickname()); + return new VerifyPasswordResponse(auth.id(), auth.username(), auth.nickname()); } - public UserProfile getById(Long id) { - UserRecord user = usersById.get(id); - if (user == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在"); - } - return toProfile(user); + public UserProfile getById(UUID id) { + return userRepository.findById(id) + .map(UserService::toProfile) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); } public UserProfile getByUsername(String username) { - UserRecord user = usersByUsername.get(username.trim()); - if (user == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在"); - } - return toProfile(user); + return userRepository.findByUsername(username.trim()) + .map(UserService::toProfile) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); } - private UserProfile toProfile(UserRecord user) { - return new UserProfile(user.getId(), user.getUsername(), user.getNickname(), user.getPhone(), user.getCreatedAt()); + private static UserProfile toProfile(UserRepository.UserRow row) { + return new UserProfile(row.id(), row.username(), row.nickname(), row.phone(), row.createdAt()); + } + + /** + * Uniqueness is decided by the database constraints (partial unique + * indexes cannot be replicated reliably in application checks); the + * violated constraint name selects the business error. + */ + private BusinessException translateDuplicate(DuplicateKeyException e) { + String message = e.getMessage() == null ? "" : e.getMessage(); + if (message.contains("users_username_key")) { + return new BusinessException(ErrorCode.USERNAME_EXISTS); + } + if (message.contains("uq_users_phone")) { + return new BusinessException(ErrorCode.PHONE_EXISTS); + } + return new BusinessException(ErrorCode.INTERNAL_ERROR); } private String normalizeBlank(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } - - private static class UserRecord { - - private final Long id; - private final String username; - private final String passwordHash; - private final String nickname; - private final String phone; - private final LocalDateTime createdAt; - - private UserRecord(Long id, String username, String passwordHash, String nickname, String phone, - LocalDateTime createdAt) { - this.id = id; - this.username = username; - this.passwordHash = passwordHash; - this.nickname = nickname; - this.phone = phone; - this.createdAt = createdAt; - } - - private Long getId() { - return id; - } - - private String getUsername() { - return username; - } - - private String getPasswordHash() { - return passwordHash; - } - - private String getNickname() { - return nickname; - } - - private String getPhone() { - return phone; - } - - private LocalDateTime getCreatedAt() { - return createdAt; - } - } } diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/support/UuidV7.java b/patbond-user/src/main/java/com/patbond/patbond/user/support/UuidV7.java new file mode 100644 index 0000000..87b9c3e --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/support/UuidV7.java @@ -0,0 +1,29 @@ +package com.patbond.patbond.user.support; + +import java.security.SecureRandom; +import java.util.UUID; + +/** + * Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond + * timestamp, version/variant bits, 74 random bits. Time-ordered values keep + * B-tree page churn low on uuid primary keys (see the bootstrap SQL "UUID + * note"); the database DEFAULT gen_random_uuid() remains the fallback for + * rows not inserted through the application. + */ +public final class UuidV7 { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private UuidV7() { + } + + public static UUID generate() { + long timestampMs = System.currentTimeMillis(); + long randA = RANDOM.nextLong() & 0x0FFFL; + long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL; + + long msb = (timestampMs << 16) | 0x7000L | randA; + long lsb = 0x8000000000000000L | randB; + return new UUID(msb, lsb); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/web/GlobalExceptionHandler.java b/patbond-user/src/main/java/com/patbond/patbond/user/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..f876a7a --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/web/GlobalExceptionHandler.java @@ -0,0 +1,62 @@ +package com.patbond.patbond.user.web; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.common.response.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** + * Single place that turns exceptions into the {code, message, data} envelope + * with a matching HTTP status (development-plan 6.1). 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> handleBusiness(BusinessException e) { + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.failure(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleMalformedRequest(Exception e) { + return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("Unhandled exception", e); + return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage()); + } + + private static ResponseEntity> failure(ErrorCode errorCode, String message) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ApiResponse.failure(errorCode.getCode(), message)); + } +} diff --git a/patbond-user/src/main/resources/application.yml.sample b/patbond-user/src/main/resources/application.yml.sample index 8de7bc8..19d0f7f 100644 --- a/patbond-user/src/main/resources/application.yml.sample +++ b/patbond-user/src/main/resources/application.yml.sample @@ -4,3 +4,20 @@ server: spring: application: name: patbond-user + datasource: + url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond} + username: ${PATBOND_DB_USER:patbond} + password: ${PATBOND_DB_PASSWORD:patbond} + flyway: + locations: classpath:db/migration + +# Development seed data (regions reference rows) is opt-in. To load it, +# activate a dev profile that widens the Flyway locations: +# +# --- +# spring: +# config: +# activate: +# on-profile: dev +# flyway: +# locations: classpath:db/migration,classpath:db/dev diff --git a/patbond-user/src/main/resources/db/dev/afterMigrate__dev_seed.sql b/patbond-user/src/main/resources/db/dev/afterMigrate__dev_seed.sql new file mode 100644 index 0000000..f7f77cf --- /dev/null +++ b/patbond-user/src/main/resources/db/dev/afterMigrate__dev_seed.sql @@ -0,0 +1,28 @@ +-- Development seed data (Flyway afterMigrate callback). +-- +-- NOT executed by default: this directory is only picked up when the dev +-- profile adds it to the Flyway locations, e.g. +-- +-- spring: +-- config: +-- activate: +-- on-profile: dev +-- flyway: +-- locations: classpath:db/migration,classpath:db/dev +-- +-- Deliberately contains reference data only (selectable regions). Fixture +-- accounts, credentials and pre-provisioned sessions from the bootstrap SQL +-- are NOT carried over: development logins are created through the real +-- register API so the whole persistence path is exercised. +-- Idempotent so repeated startups are safe. + +INSERT INTO platform.regions + (id, code, province_name, city_name, district_name, latitude, longitude) +VALUES + ('10000000-0000-7000-8000-000000000001', 'CN-BJ-CY', '北京市', '北京', '朝阳区', 39.921900, 116.443550), + ('10000000-0000-7000-8000-000000000002', 'CN-SH-PD', '上海市', '上海', '浦东新区', 31.221140, 121.544090), + ('10000000-0000-7000-8000-000000000003', 'CN-GD-SZ-NS', '广东省', '深圳', '南山区', 22.533320, 113.930410), + ('10000000-0000-7000-8000-000000000004', 'CN-SC-CD-GX', '四川省', '成都', '高新区', 30.544730, 104.069760), + ('10000000-0000-7000-8000-000000000005', 'CN-ZJ-HZ-XH', '浙江省', '杭州', '西湖区', 30.259610, 120.130260), + ('10000000-0000-7000-8000-000000000006', 'CN-HLJ-HEB-DL', '黑龙江省', '哈尔滨', '道里区', 45.755020, 126.616990) +ON CONFLICT (id) DO NOTHING; diff --git a/patbond-user/src/main/resources/db/migration/V1__identity_media_baseline.sql b/patbond-user/src/main/resources/db/migration/V1__identity_media_baseline.sql new file mode 100644 index 0000000..a6481ae --- /dev/null +++ b/patbond-user/src/main/resources/db/migration/V1__identity_media_baseline.sql @@ -0,0 +1,277 @@ +/* + V1 baseline: identity + media schemas, extracted from + patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model). + + Scope notes: + - identity and media are baselined together so the cross-schema FK + identity.users.avatar_asset_id -> media.assets(id) can be kept as-is. + - The platform schema is included only for the two hard dependencies of + identity: platform.set_updated_at() (triggers) and platform.regions + (FKs from identity.user_addresses / identity.user_preferences). + platform.distance_km and the other platform tables stay out of scope. + - Structure only. No fixture accounts, sessions or demo rows; development + seed data lives in db/dev/ and is not executed by default. + - Never edit this file after release; subsequent changes go into V2+. +*/ + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE SCHEMA platform; +CREATE SCHEMA identity; +CREATE SCHEMA media; + +COMMENT ON SCHEMA platform IS 'Shared reference data, notifications and reliable outbox'; +COMMENT ON SCHEMA identity IS 'Accounts, credentials, sessions and user preferences'; +COMMENT ON SCHEMA media IS 'Metadata for objects stored in S3/OSS/MinIO or external fixtures'; + +CREATE FUNCTION platform.set_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := clock_timestamp(); + RETURN NEW; +END; +$$; + +-- Flat selectable region rows avoid recursive province/city/district queries. +CREATE TABLE platform.regions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(32) NOT NULL UNIQUE, + country_code char(2) NOT NULL DEFAULT 'CN', + province_name varchar(64) NOT NULL, + city_name varchar(64) NOT NULL, + district_name varchar(64) NOT NULL, + latitude numeric(9,6) NOT NULL, + longitude numeric(9,6) NOT NULL, + timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai', + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_regions_code CHECK (code = btrim(code) AND char_length(code) BETWEEN 2 AND 32), + CONSTRAINT ck_regions_latitude CHECK (latitude BETWEEN -90 AND 90), + CONSTRAINT ck_regions_longitude CHECK (longitude BETWEEN -180 AND 180) +); + +CREATE INDEX ix_regions_city_district + ON platform.regions (city_name, district_name) + WHERE enabled; + +CREATE TABLE identity.users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username citext NOT NULL UNIQUE, + nickname varchar(32), + phone_e164 varchar(16), + email citext, + bio varchar(300), + avatar_asset_id uuid, + status varchar(16) NOT NULL DEFAULT 'active', + phone_verified_at timestamptz, + email_verified_at timestamptz, + last_login_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_users_username CHECK ( + username::text = btrim(username::text) + AND char_length(username::text) BETWEEN 3 AND 32 + ), + CONSTRAINT ck_users_nickname CHECK ( + nickname IS NULL OR (nickname = btrim(nickname) AND char_length(nickname) BETWEEN 1 AND 32) + ), + CONSTRAINT ck_users_phone CHECK ( + phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9][0-9]{7,14}$' + ), + CONSTRAINT ck_users_status CHECK (status IN ('active', 'locked', 'disabled', 'deleted')), + CONSTRAINT ck_users_version CHECK (version >= 0), + CONSTRAINT ck_users_deleted_state CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE UNIQUE INDEX uq_users_phone + ON identity.users (phone_e164) + WHERE phone_e164 IS NOT NULL AND status <> 'deleted'; + +CREATE UNIQUE INDEX uq_users_email + ON identity.users (email) + WHERE email IS NOT NULL AND status <> 'deleted'; + +CREATE INDEX ix_users_avatar_asset ON identity.users (avatar_asset_id); + +CREATE TABLE identity.user_credentials ( + user_id uuid PRIMARY KEY REFERENCES identity.users(id) ON DELETE RESTRICT, + password_hash varchar(255) NOT NULL, + hash_algorithm varchar(16) NOT NULL DEFAULT 'bcrypt', + password_changed_at timestamptz NOT NULL DEFAULT now(), + failed_login_count integer NOT NULL DEFAULT 0, + failure_window_started_at timestamptz, + last_failed_at timestamptz, + locked_until timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_credentials_algorithm CHECK (hash_algorithm IN ('bcrypt', 'argon2id')), + CONSTRAINT ck_credentials_failed_count CHECK (failed_login_count >= 0), + CONSTRAINT ck_credentials_hash CHECK (char_length(password_hash) BETWEEN 20 AND 255) +); + +CREATE TABLE identity.auth_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + token_family_id uuid NOT NULL, + refresh_token_hash bytea NOT NULL UNIQUE, + access_token_jti varchar(64), + device_id varchar(128), + device_name varchar(128), + user_agent varchar(512), + ip_address inet, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + revoke_reason varchar(128), + rotated_at timestamptz, + replaced_by_session_id uuid REFERENCES identity.auth_sessions(id) ON DELETE SET NULL, + UNIQUE (replaced_by_session_id), + CONSTRAINT ck_sessions_refresh_hash CHECK (octet_length(refresh_token_hash) = 32), + CONSTRAINT ck_sessions_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_sessions_revoked_at CHECK (revoked_at IS NULL OR revoked_at >= created_at), + CONSTRAINT ck_sessions_rotation CHECK ( + replaced_by_session_id IS NULL + OR (replaced_by_session_id <> id AND revoked_at IS NOT NULL AND rotated_at IS NOT NULL) + ) +); + +CREATE INDEX ix_auth_sessions_user_created + ON identity.auth_sessions (user_id, created_at DESC); +CREATE INDEX ix_auth_sessions_active_expiry + ON identity.auth_sessions (expires_at) + WHERE revoked_at IS NULL; +CREATE INDEX ix_auth_sessions_family + ON identity.auth_sessions (token_family_id, created_at DESC); +CREATE UNIQUE INDEX uq_auth_sessions_access_jti + ON identity.auth_sessions (access_token_jti) + WHERE access_token_jti IS NOT NULL; + +CREATE TABLE identity.user_addresses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + region_id uuid NOT NULL REFERENCES platform.regions(id) ON DELETE RESTRICT, + label varchar(32) NOT NULL, + recipient_name varchar(64), + recipient_phone_e164 varchar(16), + address_line varchar(300) NOT NULL, + latitude numeric(9,6), + longitude numeric(9,6), + is_default boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT ck_user_addresses_label CHECK (label = btrim(label) AND char_length(label) BETWEEN 1 AND 32), + CONSTRAINT ck_user_addresses_line CHECK (address_line = btrim(address_line) AND char_length(address_line) BETWEEN 3 AND 300), + CONSTRAINT ck_user_addresses_phone CHECK ( + recipient_phone_e164 IS NULL OR recipient_phone_e164 ~ '^\+[1-9][0-9]{7,14}$' + ), + CONSTRAINT ck_user_addresses_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR ( + latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + ) + ) +); + +CREATE INDEX ix_user_addresses_user ON identity.user_addresses (user_id); +CREATE INDEX ix_user_addresses_region ON identity.user_addresses (region_id); +CREATE UNIQUE INDEX uq_user_addresses_default + ON identity.user_addresses (user_id) + WHERE is_default AND deleted_at IS NULL; + +CREATE TABLE identity.user_preferences ( + user_id uuid PRIMARY KEY REFERENCES identity.users(id) ON DELETE CASCADE, + default_region_id uuid REFERENCES platform.regions(id) ON DELETE SET NULL, + locale varchar(16) NOT NULL DEFAULT 'zh-CN', + timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai', + allow_precise_location boolean NOT NULL DEFAULT false, + marketing_notifications boolean NOT NULL DEFAULT false, + community_notifications boolean NOT NULL DEFAULT true, + health_reminders boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_user_preferences_locale CHECK (char_length(locale) BETWEEN 2 AND 16), + CONSTRAINT ck_user_preferences_timezone CHECK (char_length(timezone) BETWEEN 3 AND 64) +); + +CREATE INDEX ix_user_preferences_region ON identity.user_preferences (default_region_id); + +CREATE TABLE media.assets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id uuid REFERENCES identity.users(id) ON DELETE RESTRICT, + kind varchar(16) NOT NULL, + purpose varchar(32) NOT NULL, + storage_type varchar(16) NOT NULL DEFAULT 'object', + bucket varchar(63), + object_key varchar(1024), + external_url varchar(1024), + mime_type varchar(127) NOT NULL, + byte_size bigint, + sha256 bytea, + width_px integer, + height_px integer, + duration_ms bigint, + status varchar(16) NOT NULL DEFAULT 'uploading', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + ready_at timestamptz, + deleted_at timestamptz, + CONSTRAINT ck_media_kind CHECK (kind IN ('image', 'video', 'document')), + CONSTRAINT ck_media_storage_type CHECK (storage_type IN ('object', 'external')), + CONSTRAINT ck_media_location CHECK ( + ( + storage_type = 'object' AND bucket IS NOT NULL + AND object_key IS NOT NULL AND char_length(btrim(object_key)) > 0 + AND external_url IS NULL + ) + OR ( + storage_type = 'external' + AND external_url IS NOT NULL AND char_length(btrim(external_url)) > 0 + AND bucket IS NULL AND object_key IS NULL + ) + ), + CONSTRAINT ck_media_size CHECK (byte_size IS NULL OR byte_size >= 0), + CONSTRAINT ck_media_hash CHECK (sha256 IS NULL OR octet_length(sha256) = 32), + CONSTRAINT ck_media_dimensions CHECK ( + (width_px IS NULL OR width_px > 0) + AND (height_px IS NULL OR height_px > 0) + AND (duration_ms IS NULL OR duration_ms >= 0) + ), + CONSTRAINT ck_media_status CHECK (status IN ('uploading', 'ready', 'failed', 'deleted')), + CONSTRAINT ck_media_ready CHECK (status <> 'ready' OR ready_at IS NOT NULL), + CONSTRAINT ck_media_deleted CHECK (status <> 'deleted' OR deleted_at IS NOT NULL) +); + +CREATE UNIQUE INDEX uq_media_object + ON media.assets (bucket, object_key) + WHERE storage_type = 'object'; +CREATE UNIQUE INDEX uq_media_external_url + ON media.assets (external_url) + WHERE storage_type = 'external'; +CREATE INDEX ix_media_owner_created ON media.assets (owner_user_id, created_at DESC); +CREATE INDEX ix_media_uploading_created + ON media.assets (created_at) + WHERE status = 'uploading'; + +ALTER TABLE identity.users + ADD CONSTRAINT fk_users_avatar_asset + FOREIGN KEY (avatar_asset_id) REFERENCES media.assets(id) ON DELETE SET NULL; + +-- Automatic updated_at maintenance. Business version increments remain explicit +-- so optimistic locking stays visible in repository update statements. +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON identity.users + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_credentials_updated_at BEFORE UPDATE ON identity.user_credentials + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_addresses_updated_at BEFORE UPDATE ON identity.user_addresses + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_preferences_updated_at BEFORE UPDATE ON identity.user_preferences + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_media_updated_at BEFORE UPDATE ON media.assets + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/TestcontainersConfiguration.java b/patbond-user/src/test/java/com/patbond/patbond/user/TestcontainersConfiguration.java new file mode 100644 index 0000000..b732ab9 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/TestcontainersConfiguration.java @@ -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")); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/UserApplicationTests.java b/patbond-user/src/test/java/com/patbond/patbond/user/UserApplicationTests.java index 90cc650..821cb55 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/UserApplicationTests.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/UserApplicationTests.java @@ -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. } } diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java index 86217f4..045aab9 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java @@ -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)); } } diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/persistence/UserPersistenceIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/persistence/UserPersistenceIntegrationTest.java new file mode 100644 index 0000000..69aa4b5 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/persistence/UserPersistenceIntegrationTest.java @@ -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 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"); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/support/UuidV7Test.java b/patbond-user/src/test/java/com/patbond/patbond/user/support/UuidV7Test.java new file mode 100644 index 0000000..c74669c --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/support/UuidV7Test.java @@ -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 seen = new HashSet<>(); + for (int i = 0; i < 10_000; i++) { + assertThat(seen.add(UuidV7.generate())).isTrue(); + } + } +}