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:
@@ -13,6 +13,7 @@ Patbond API is a Spring Boot multi-module backend.
|
|||||||
- Java 17 (build baseline; use JDK 17 for release builds)
|
- Java 17 (build baseline; use JDK 17 for release builds)
|
||||||
- Spring Boot 3.5.16
|
- Spring Boot 3.5.16
|
||||||
- Spring Cloud 2025.0.3 (OpenFeign only)
|
- Spring Cloud 2025.0.3 (OpenFeign only)
|
||||||
|
- PostgreSQL 16 + Flyway (patbond-user owns the `identity`/`media` schemas)
|
||||||
- Maven (use the committed Maven Wrapper `./mvnw`)
|
- Maven (use the committed Maven Wrapper `./mvnw`)
|
||||||
|
|
||||||
## Build and Test
|
## Build and Test
|
||||||
@@ -21,6 +22,10 @@ Patbond API is a Spring Boot multi-module backend.
|
|||||||
./mvnw clean test
|
./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.:
|
If your default JDK is not 17, point `JAVA_HOME` at a JDK 17 installation first, e.g.:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -29,6 +34,12 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
|
|||||||
|
|
||||||
## Run
|
## 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`
|
Each service ships a committed `application.yml.sample`; the real `application.yml`
|
||||||
is git-ignored. First copy the samples (defaults work locally, overrides via
|
is git-ignored. First copy the samples (defaults work locally, overrides via
|
||||||
environment variables):
|
environment variables):
|
||||||
@@ -66,6 +77,9 @@ curl -X POST http://127.0.0.1:8081/auth/register \
|
|||||||
| `PATBOND_USER_PORT` | `8082` | patbond-user |
|
| `PATBOND_USER_PORT` | `8082` | patbond-user |
|
||||||
| `PATBOND_AUTH_PORT` | `8081` | patbond-auth |
|
| `PATBOND_AUTH_PORT` | `8081` | patbond-auth |
|
||||||
| `PATBOND_USER_SERVICE_URL` | `http://127.0.0.1:8082` | patbond-auth (Feign target for patbond-user) |
|
| `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
|
Machine-specific values live in the git-ignored `application.yml` (copied from the
|
||||||
committed `.sample`); never commit secrets to the samples.
|
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 id: `GET /internal/users/{id}`
|
||||||
- Get user by username: `GET /internal/users/by-username/{username}`
|
- Get user by username: `GET /internal/users/by-username/{username}`
|
||||||
|
|
||||||
> Note: the user store is currently in-memory (prototype); data is lost on restart.
|
> Note: user data is persisted in PostgreSQL (`identity.users` /
|
||||||
> Persistence (PostgreSQL + Flyway), verifiable JWT tokens, and `/internal` access
|
> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in
|
||||||
> control are planned in iteration 1 follow-up tasks.
|
> 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.
|
||||||
|
|||||||
@@ -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;
|
package com.patbond.patbond.auth.dto;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class AuthTokenResponse {
|
public class AuthTokenResponse {
|
||||||
|
|
||||||
private String tokenType;
|
private String tokenType;
|
||||||
private String accessToken;
|
private String accessToken;
|
||||||
private LocalDateTime expiresAt;
|
private LocalDateTime expiresAt;
|
||||||
private Long userId;
|
private UUID userId;
|
||||||
private String username;
|
private String username;
|
||||||
private String nickname;
|
private String nickname;
|
||||||
|
|
||||||
public AuthTokenResponse(String tokenType, String accessToken, LocalDateTime expiresAt,
|
public AuthTokenResponse(String tokenType, String accessToken, LocalDateTime expiresAt,
|
||||||
Long userId, String username, String nickname) {
|
UUID userId, String username, String nickname) {
|
||||||
this.tokenType = tokenType;
|
this.tokenType = tokenType;
|
||||||
this.accessToken = accessToken;
|
this.accessToken = accessToken;
|
||||||
this.expiresAt = expiresAt;
|
this.expiresAt = expiresAt;
|
||||||
@@ -33,7 +34,7 @@ public class AuthTokenResponse {
|
|||||||
return expiresAt;
|
return expiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getUserId() {
|
public UUID getUserId() {
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.patbond.patbond.auth.dto;
|
package com.patbond.patbond.auth.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
public class RegisterRequest {
|
public class RegisterRequest {
|
||||||
@@ -16,7 +17,8 @@ public class RegisterRequest {
|
|||||||
@Size(max = 32, message = "昵称长度不能超过32位")
|
@Size(max = 32, message = "昵称长度不能超过32位")
|
||||||
private String nickname;
|
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;
|
private String phone;
|
||||||
|
|
||||||
public String getUsername() {
|
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.AuthTokenResponse;
|
||||||
import com.patbond.patbond.auth.dto.LoginRequest;
|
import com.patbond.patbond.auth.dto.LoginRequest;
|
||||||
import com.patbond.patbond.auth.dto.RegisterRequest;
|
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.response.ApiResponse;
|
||||||
import com.patbond.patbond.common.user.CreateUserRequest;
|
import com.patbond.patbond.common.user.CreateUserRequest;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -44,7 +44,7 @@ public class AuthService {
|
|||||||
return buildToken(user.getUserId(), user.getUsername(), user.getNickname());
|
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(
|
return new AuthTokenResponse(
|
||||||
"Bearer",
|
"Bearer",
|
||||||
UUID.randomUUID().toString().replace("-", ""),
|
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) {
|
private <T> T requireData(ApiResponse<T> response, String defaultMessage) {
|
||||||
if (response == null || !response.isSuccess() || response.getData() == null) {
|
if (response == null || !response.isSuccess() || response.getData() == null) {
|
||||||
String message = response == null || response.getMessage() == null ? defaultMessage : response.getMessage();
|
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();
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-28
@@ -1,6 +1,8 @@
|
|||||||
package com.patbond.patbond.auth.controller;
|
package com.patbond.patbond.auth.controller;
|
||||||
|
|
||||||
import com.patbond.patbond.auth.client.UserClient;
|
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.response.ApiResponse;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
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 org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
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.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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
|
* 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
|
* a Mockito mock so no user-service process is required. Downstream business
|
||||||
* CURRENT behaviour of the in-memory prototype (opaque token, business
|
* failures are simulated as the BusinessException the ApiErrorDecoder raises,
|
||||||
* failures folded to 400, FeignException not yet translated) — recorded here
|
* so these tests pin the FIXED error contract: 409/401/400 pass through to
|
||||||
* as the baseline the upcoming error-contract work will change deliberately.
|
* the client with stable business codes instead of collapsing to 400/500
|
||||||
|
* (audit issue M1); transport-level Feign failures answer 503.
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@@ -43,8 +46,10 @@ class AuthControllerTest {
|
|||||||
@MockitoBean
|
@MockitoBean
|
||||||
private UserClient userClient;
|
private UserClient userClient;
|
||||||
|
|
||||||
|
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
|
||||||
|
|
||||||
private static final String REGISTER_BODY = """
|
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 = """
|
private static final String LOGIN_BODY = """
|
||||||
{"username":"alice","password":"secret123"}
|
{"username":"alice","password":"secret123"}
|
||||||
@@ -52,7 +57,7 @@ class AuthControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void registerReturnsTokenWhenUserServiceSucceeds() throws Exception {
|
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));
|
when(userClient.createUser(any())).thenReturn(ApiResponse.success(profile));
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/register")
|
mockMvc.perform(post("/auth/register")
|
||||||
@@ -62,18 +67,21 @@ class AuthControllerTest {
|
|||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
.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"));
|
.andExpect(jsonPath("$.data.username").value("alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void registerReturnsBadRequestWhenUserServiceReportsFailure() throws Exception {
|
void registerPropagatesDuplicateUsernameAsConflict() throws Exception {
|
||||||
when(userClient.createUser(any())).thenReturn(ApiResponse.failure(409, "用户名已存在"));
|
when(userClient.createUser(any()))
|
||||||
|
.thenThrow(new BusinessException(ErrorCode.USERNAME_EXISTS));
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/register")
|
mockMvc.perform(post("/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REGISTER_BODY))
|
.content(REGISTER_BODY))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value(40900))
|
||||||
|
.andExpect(jsonPath("$.message").value("用户名已存在"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -81,13 +89,25 @@ class AuthControllerTest {
|
|||||||
mockMvc.perform(post("/auth/register")
|
mockMvc.perform(post("/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
|
.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
|
@Test
|
||||||
void loginReturnsTokenWhenPasswordVerified() throws Exception {
|
void loginReturnsTokenWhenPasswordVerified() throws Exception {
|
||||||
when(userClient.verifyPassword(any()))
|
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")
|
mockMvc.perform(post("/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -95,17 +115,20 @@ class AuthControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.data.userId").value(1));
|
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginReturnsBadRequestWhenVerificationReportsFailure() throws Exception {
|
void loginPropagatesWrongPasswordAsUnauthorized() throws Exception {
|
||||||
when(userClient.verifyPassword(any())).thenReturn(ApiResponse.failure(401, "用户名或密码错误"));
|
when(userClient.verifyPassword(any()))
|
||||||
|
.thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS));
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/login")
|
mockMvc.perform(post("/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(LOGIN_BODY))
|
.content(LOGIN_BODY))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40100))
|
||||||
|
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -113,22 +136,22 @@ class AuthControllerTest {
|
|||||||
mockMvc.perform(post("/auth/login")
|
mockMvc.perform(post("/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"username\":\"\",\"password\":\"\"}"))
|
.content("{\"username\":\"\",\"password\":\"\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginPropagatesFeignExceptionUnhandled() throws Exception {
|
void loginAnswersServiceUnavailableOnTransportLevelFeignFailure() throws Exception {
|
||||||
Request request = Request.create(Request.HttpMethod.POST, "/internal/users/verify-password",
|
Request request = Request.create(Request.HttpMethod.POST, "/internal/users/verify-password",
|
||||||
Map.of(), null, StandardCharsets.UTF_8, null);
|
Map.of(), null, StandardCharsets.UTF_8, null);
|
||||||
FeignException unauthorized = FeignException.errorStatus("UserClient#verifyPassword",
|
FeignException transportFailure = FeignException.errorStatus("UserClient#verifyPassword",
|
||||||
Response.builder().status(401).request(request).build());
|
Response.builder().status(502).request(request).build());
|
||||||
when(userClient.verifyPassword(any())).thenThrow(unauthorized);
|
when(userClient.verifyPassword(any())).thenThrow(transportFailure);
|
||||||
|
|
||||||
// Current baseline: a downstream 401 raised as FeignException is not
|
mockMvc.perform(post("/auth/login")
|
||||||
// translated, so it escapes the MVC layer (a real deployment answers 500).
|
|
||||||
assertThatThrownBy(() -> mockMvc.perform(post("/auth/login")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(LOGIN_BODY)))
|
.content(LOGIN_BODY))
|
||||||
.hasCauseInstanceOf(FeignException.class);
|
.andExpect(status().isServiceUnavailable())
|
||||||
|
.andExpect(jsonPath("$.code").value(50300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.patbond.patbond.common.user;
|
package com.patbond.patbond.common.user;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
public class CreateUserRequest {
|
public class CreateUserRequest {
|
||||||
@@ -16,7 +17,8 @@ public class CreateUserRequest {
|
|||||||
@Size(max = 32, message = "昵称长度不能超过32位")
|
@Size(max = 32, message = "昵称长度不能超过32位")
|
||||||
private String nickname;
|
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;
|
private String phone;
|
||||||
|
|
||||||
public CreateUserRequest() {
|
public CreateUserRequest() {
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package com.patbond.patbond.common.user;
|
package com.patbond.patbond.common.user;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class UserProfile {
|
public class UserProfile {
|
||||||
|
|
||||||
private Long id;
|
private UUID id;
|
||||||
private String username;
|
private String username;
|
||||||
private String nickname;
|
private String nickname;
|
||||||
private String phone;
|
private String phone;
|
||||||
private LocalDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
public UserProfile() {
|
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.id = id;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.nickname = nickname;
|
this.nickname = nickname;
|
||||||
@@ -21,11 +22,11 @@ public class UserProfile {
|
|||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public UUID getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setId(Long id) {
|
public void setId(UUID id) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +54,11 @@ public class UserProfile {
|
|||||||
this.phone = phone;
|
this.phone = phone;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public OffsetDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -1,25 +1,27 @@
|
|||||||
package com.patbond.patbond.common.user;
|
package com.patbond.patbond.common.user;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class VerifyPasswordResponse {
|
public class VerifyPasswordResponse {
|
||||||
|
|
||||||
private Long userId;
|
private UUID userId;
|
||||||
private String username;
|
private String username;
|
||||||
private String nickname;
|
private String nickname;
|
||||||
|
|
||||||
public VerifyPasswordResponse() {
|
public VerifyPasswordResponse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public VerifyPasswordResponse(Long userId, String username, String nickname) {
|
public VerifyPasswordResponse(UUID userId, String username, String nickname) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.nickname = nickname;
|
this.nickname = nickname;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getUserId() {
|
public UUID getUserId() {
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUserId(Long userId) {
|
public void setUserId(UUID userId) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,43 @@
|
|||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-crypto</artifactId>
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/users")
|
@RequestMapping("/internal/users")
|
||||||
public class UserController {
|
public class UserController {
|
||||||
@@ -35,7 +37,7 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ApiResponse<UserProfile> getById(@PathVariable Long id) {
|
public ApiResponse<UserProfile> getById(@PathVariable UUID id) {
|
||||||
return ApiResponse.success(userService.getById(id));
|
return ApiResponse.success(userService.getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<UserRow> 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<UserRow> 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<AuthRow> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,120 +1,98 @@
|
|||||||
package com.patbond.patbond.user.service;
|
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.CreateUserRequest;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
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.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
|
|
||||||
private final AtomicLong idGenerator = new AtomicLong(1);
|
private final UserRepository userRepository;
|
||||||
private final Map<Long, UserRecord> usersById = new ConcurrentHashMap<>();
|
|
||||||
private final Map<String, UserRecord> usersByUsername = new ConcurrentHashMap<>();
|
|
||||||
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
public synchronized UserProfile createUser(CreateUserRequest request) {
|
/**
|
||||||
String username = request.getUsername().trim();
|
* Matched against when the username does not exist, so the response time
|
||||||
if (usersByUsername.containsKey(username)) {
|
* of verifyPassword does not reveal whether an account exists.
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "用户名已存在");
|
*/
|
||||||
|
private final String unknownUserHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
public UserService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
UserRecord user = new UserRecord(
|
@Transactional
|
||||||
idGenerator.getAndIncrement(),
|
public UserProfile createUser(CreateUserRequest request) {
|
||||||
username,
|
UUID id = UuidV7.generate();
|
||||||
passwordEncoder.encode(request.getPassword()),
|
String username = request.getUsername().trim();
|
||||||
normalizeBlank(request.getNickname()),
|
String nickname = normalizeBlank(request.getNickname());
|
||||||
normalizeBlank(request.getPhone()),
|
String phone = normalizeBlank(request.getPhone());
|
||||||
LocalDateTime.now()
|
|
||||||
);
|
OffsetDateTime createdAt;
|
||||||
usersById.put(user.getId(), user);
|
try {
|
||||||
usersByUsername.put(user.getUsername(), user);
|
createdAt = userRepository.insertUser(id, username, nickname, phone);
|
||||||
return toProfile(user);
|
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) {
|
public VerifyPasswordResponse verifyPassword(VerifyPasswordRequest request) {
|
||||||
UserRecord user = usersByUsername.get(request.getUsername().trim());
|
UserRepository.AuthRow auth = userRepository.findAuthByUsername(request.getUsername().trim())
|
||||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
.orElse(null);
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误");
|
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) {
|
public UserProfile getById(UUID id) {
|
||||||
UserRecord user = usersById.get(id);
|
return userRepository.findById(id)
|
||||||
if (user == null) {
|
.map(UserService::toProfile)
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||||
}
|
|
||||||
return toProfile(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserProfile getByUsername(String username) {
|
public UserProfile getByUsername(String username) {
|
||||||
UserRecord user = usersByUsername.get(username.trim());
|
return userRepository.findByUsername(username.trim())
|
||||||
if (user == null) {
|
.map(UserService::toProfile)
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||||
}
|
|
||||||
return toProfile(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserProfile toProfile(UserRecord user) {
|
private static UserProfile toProfile(UserRepository.UserRow row) {
|
||||||
return new UserProfile(user.getId(), user.getUsername(), user.getNickname(), user.getPhone(), user.getCreatedAt());
|
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) {
|
private String normalizeBlank(String value) {
|
||||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||||
|
return ResponseEntity.status(e.getHttpStatus())
|
||||||
|
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
|
||||||
|
String message = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(FieldError::getDefaultMessage)
|
||||||
|
.orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||||
|
return failure(ErrorCode.VALIDATION_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class})
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,20 @@ server:
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: patbond-user
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.patbond.patbond.user;
|
||||||
|
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Testcontainers setup: a disposable postgres:16 (the production
|
||||||
|
* target version) wired into the Spring context via @ServiceConnection.
|
||||||
|
* Flyway runs the real migrations against it on context startup, so every
|
||||||
|
* @SpringBootTest in this module exercises the V1 baseline on a clean
|
||||||
|
* database. No local PostgreSQL installation is used or required.
|
||||||
|
*/
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
public class TestcontainersConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ServiceConnection
|
||||||
|
PostgreSQLContainer<?> postgresContainer() {
|
||||||
|
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,15 @@ package com.patbond.patbond.user;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
class UserApplicationTests {
|
class UserApplicationTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {
|
||||||
// Verifies the user service starts with the committed application.yml
|
// Verifies the user service starts against a clean PostgreSQL 16
|
||||||
// and no external infrastructure (post ADR-002 Nacos removal).
|
// (Testcontainers) with the Flyway baseline applied on boot.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-17
@@ -1,45 +1,63 @@
|
|||||||
package com.patbond.patbond.user.controller;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockMvc tests against the current in-memory UserService implementation.
|
* MockMvc tests against the PostgreSQL-backed UserService (Testcontainers).
|
||||||
* The service is a stateful singleton within the shared test context, so each
|
* The database lives for the whole test context, so each test uses its own
|
||||||
* test uses its own username.
|
* username/phone to stay independent.
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
class UserControllerTest {
|
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
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
private static String createUserBody(String username) {
|
private static String createUserBody(String username) {
|
||||||
return """
|
return """
|
||||||
{"username":"%s","password":"secret123","nickname":"Nick","phone":"13800000000"}
|
{"username":"%s","password":"secret123","nickname":"Nick"}
|
||||||
""".formatted(username);
|
""".formatted(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String createUserBody(String username, String phone) {
|
||||||
|
return """
|
||||||
|
{"username":"%s","password":"secret123","nickname":"Nick","phone":"%s"}
|
||||||
|
""".formatted(username, phone);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createUserReturnsProfileWithoutPassword() throws Exception {
|
void createUserReturnsUuidProfileWithoutPassword() throws Exception {
|
||||||
mockMvc.perform(post("/internal/users")
|
mockMvc.perform(post("/internal/users")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(createUserBody("alice_create")))
|
.content(createUserBody("alice_create", "+8613800000101")))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.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.username").value("alice_create"))
|
||||||
.andExpect(jsonPath("$.data.nickname").value("Nick"))
|
.andExpect(jsonPath("$.data.nickname").value("Nick"))
|
||||||
|
.andExpect(jsonPath("$.data.phone").value("+8613800000101"))
|
||||||
|
.andExpect(jsonPath("$.data.createdAt").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.data.password").doesNotExist());
|
.andExpect(jsonPath("$.data.password").doesNotExist());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +71,37 @@ class UserControllerTest {
|
|||||||
mockMvc.perform(post("/internal/users")
|
mockMvc.perform(post("/internal/users")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(createUserBody("bob_dup")))
|
.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
|
@Test
|
||||||
@@ -61,7 +109,19 @@ class UserControllerTest {
|
|||||||
mockMvc.perform(post("/internal/users")
|
mockMvc.perform(post("/internal/users")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
|
.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
|
@Test
|
||||||
@@ -76,7 +136,7 @@ class UserControllerTest {
|
|||||||
.content("{\"username\":\"carol_verify\",\"password\":\"secret123\"}"))
|
.content("{\"username\":\"carol_verify\",\"password\":\"secret123\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.userId").isNumber())
|
.andExpect(jsonPath("$.data.userId", matchesPattern(UUID_PATTERN)))
|
||||||
.andExpect(jsonPath("$.data.username").value("carol_verify"));
|
.andExpect(jsonPath("$.data.username").value("carol_verify"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +150,8 @@ class UserControllerTest {
|
|||||||
mockMvc.perform(post("/internal/users/verify-password")
|
mockMvc.perform(post("/internal/users/verify-password")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"username\":\"dave_wrongpw\",\"password\":\"wrong-password\"}"))
|
.content("{\"username\":\"dave_wrongpw\",\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40100));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -98,27 +159,37 @@ class UserControllerTest {
|
|||||||
mockMvc.perform(post("/internal/users/verify-password")
|
mockMvc.perform(post("/internal/users/verify-password")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"username\":\"no_such_user\",\"password\":\"whatever1\"}"))
|
.content("{\"username\":\"no_such_user\",\"password\":\"whatever1\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40100));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getByIdReturnsProfileForExistingUser() throws Exception {
|
void getByIdReturnsProfileForExistingUser() throws Exception {
|
||||||
String location = mockMvc.perform(post("/internal/users")
|
String body = mockMvc.perform(post("/internal/users")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(createUserBody("erin_getbyid")))
|
.content(createUserBody("erin_getbyid")))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andReturn().getResponse().getContentAsString();
|
.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))
|
mockMvc.perform(get("/internal/users/{id}", id))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.id").value(id))
|
||||||
.andExpect(jsonPath("$.data.username").value("erin_getbyid"));
|
.andExpect(jsonPath("$.data.username").value("erin_getbyid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getByIdForUnknownUserReturnsNotFound() throws Exception {
|
void getByIdForUnknownUserReturnsNotFound() throws Exception {
|
||||||
mockMvc.perform(get("/internal/users/{id}", 999999L))
|
mockMvc.perform(get("/internal/users/{id}", UUID.randomUUID()))
|
||||||
.andExpect(status().isNotFound());
|
.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
|
@Test
|
||||||
@@ -133,6 +204,7 @@ class UserControllerTest {
|
|||||||
.andExpect(jsonPath("$.data.username").value("frank_byname"));
|
.andExpect(jsonPath("$.data.username").value("frank_byname"));
|
||||||
|
|
||||||
mockMvc.perform(get("/internal/users/by-username/{username}", "ghost_user"))
|
mockMvc.perform(get("/internal/users/by-username/{username}", "ghost_user"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40400));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package com.patbond.patbond.user.persistence;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.user.CreateUserRequest;
|
||||||
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
|
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||||
|
import com.patbond.patbond.user.service.UserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the Flyway V1 baseline and the real persistence semantics against
|
||||||
|
* a clean postgres:16 container: migrations apply, constraints hold, and a
|
||||||
|
* registered user is durably stored in PostgreSQL (readable over a fresh raw
|
||||||
|
* JDBC connection, i.e. independent of any application-process memory — the
|
||||||
|
* restart-survival semantics that killed the old ConcurrentHashMap storage).
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class UserPersistenceIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PostgreSQLContainer<?> postgres;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flywayBaselineAppliedOnCleanPostgres16() {
|
||||||
|
Integer applied = jdbcClient.sql("""
|
||||||
|
SELECT count(*) FROM flyway_schema_history
|
||||||
|
WHERE success AND version = '1'
|
||||||
|
""")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(applied).isEqualTo(1);
|
||||||
|
|
||||||
|
List<String> tables = jdbcClient.sql("""
|
||||||
|
SELECT table_schema || '.' || table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema IN ('identity', 'media', 'platform')
|
||||||
|
ORDER BY 1
|
||||||
|
""")
|
||||||
|
.query(String.class)
|
||||||
|
.list();
|
||||||
|
assertThat(tables).contains(
|
||||||
|
"identity.users",
|
||||||
|
"identity.user_credentials",
|
||||||
|
"identity.auth_sessions",
|
||||||
|
"identity.user_addresses",
|
||||||
|
"identity.user_preferences",
|
||||||
|
"media.assets",
|
||||||
|
"platform.regions");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void devSeedIsNotLoadedByDefault() {
|
||||||
|
Integer regions = jdbcClient.sql("SELECT count(*) FROM platform.regions")
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(regions).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registeredUserIsDurablyStoredWithBcryptHash() throws SQLException {
|
||||||
|
UserProfile profile = userService.createUser(
|
||||||
|
new CreateUserRequest("persist_user", "secret123", "小柴", "+8613800000301"));
|
||||||
|
|
||||||
|
// Read back over a brand-new raw JDBC connection: what we see here is
|
||||||
|
// what any restarted process would see.
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
|
||||||
|
PreparedStatement statement = connection.prepareStatement("""
|
||||||
|
SELECT u.username::text AS username, u.phone_e164, c.password_hash
|
||||||
|
FROM identity.users u
|
||||||
|
JOIN identity.user_credentials c ON c.user_id = u.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
""")) {
|
||||||
|
statement.setObject(1, profile.getId());
|
||||||
|
try (ResultSet rs = statement.executeQuery()) {
|
||||||
|
assertThat(rs.next()).isTrue();
|
||||||
|
assertThat(rs.getString("username")).isEqualTo("persist_user");
|
||||||
|
assertThat(rs.getString("phone_e164")).isEqualTo("+8613800000301");
|
||||||
|
String hash = rs.getString("password_hash");
|
||||||
|
assertThat(hash).startsWith("$2").doesNotContain("secret123");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userIdsAreTimeOrderedUuidV7() {
|
||||||
|
UserProfile first = userService.createUser(
|
||||||
|
new CreateUserRequest("uuid7_first", "secret123", null, null));
|
||||||
|
UserProfile second = userService.createUser(
|
||||||
|
new CreateUserRequest("uuid7_second", "secret123", null, null));
|
||||||
|
|
||||||
|
assertThat(first.getId().version()).isEqualTo(7);
|
||||||
|
assertThat(second.getId().version()).isEqualTo(7);
|
||||||
|
assertThat(first.getId().variant()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void databaseRejectsNonE164PhoneEvenIfValidationWereBypassed() {
|
||||||
|
assertThatThrownBy(() -> jdbcClient.sql("""
|
||||||
|
INSERT INTO identity.users (id, username, phone_e164)
|
||||||
|
VALUES (:id, 'bypass_phone', '13800000000')
|
||||||
|
""")
|
||||||
|
.param("id", UUID.randomUUID())
|
||||||
|
.update())
|
||||||
|
.hasMessageContaining("ck_users_phone");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.patbond.patbond.user.support;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class UuidV7Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatesRfc9562Version7Variant2Values() {
|
||||||
|
UUID uuid = UuidV7.generate();
|
||||||
|
assertThat(uuid.version()).isEqualTo(7);
|
||||||
|
assertThat(uuid.variant()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void embedsCurrentUnixMillisecondTimestamp() {
|
||||||
|
long before = System.currentTimeMillis();
|
||||||
|
UUID uuid = UuidV7.generate();
|
||||||
|
long after = System.currentTimeMillis();
|
||||||
|
|
||||||
|
long embedded = uuid.getMostSignificantBits() >>> 16;
|
||||||
|
assertThat(embedded).isBetween(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatesDistinctValues() {
|
||||||
|
Set<UUID> seen = new HashSet<>();
|
||||||
|
for (int i = 0; i < 10_000; i++) {
|
||||||
|
assertThat(seen.add(UuidV7.generate())).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user