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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:34:48 +08:00
parent c7ddaecb76
commit bd20adc700
28 changed files with 1266 additions and 159 deletions
@@ -0,0 +1,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;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public class CreateUserRequest {
@@ -16,7 +17,8 @@ public class CreateUserRequest {
@Size(max = 32, message = "昵称长度不能超过32位")
private String nickname;
@Size(max = 20, message = "手机号长度不能超过20位")
// Aligned with the identity.users ck_users_phone CHECK constraint (E.164).
@Pattern(regexp = "^\\+[1-9][0-9]{7,14}$", message = "手机号必须为 E.164 格式,例如 +8613800138000")
private String phone;
public CreateUserRequest() {
@@ -1,19 +1,20 @@
package com.patbond.patbond.common.user;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.UUID;
public class UserProfile {
private Long id;
private UUID id;
private String username;
private String nickname;
private String phone;
private LocalDateTime createdAt;
private OffsetDateTime createdAt;
public UserProfile() {
}
public UserProfile(Long id, String username, String nickname, String phone, LocalDateTime createdAt) {
public UserProfile(UUID id, String username, String nickname, String phone, OffsetDateTime createdAt) {
this.id = id;
this.username = username;
this.nickname = nickname;
@@ -21,11 +22,11 @@ public class UserProfile {
this.createdAt = createdAt;
}
public Long getId() {
public UUID getId() {
return id;
}
public void setId(Long id) {
public void setId(UUID id) {
this.id = id;
}
@@ -53,11 +54,11 @@ public class UserProfile {
this.phone = phone;
}
public LocalDateTime getCreatedAt() {
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}
@@ -1,25 +1,27 @@
package com.patbond.patbond.common.user;
import java.util.UUID;
public class VerifyPasswordResponse {
private Long userId;
private UUID userId;
private String username;
private String nickname;
public VerifyPasswordResponse() {
}
public VerifyPasswordResponse(Long userId, String username, String nickname) {
public VerifyPasswordResponse(UUID userId, String username, String nickname) {
this.userId = userId;
this.username = username;
this.nickname = nickname;
}
public Long getUserId() {
public UUID getUserId() {
return userId;
}
public void setUserId(Long userId) {
public void setUserId(UUID userId) {
this.userId = userId;
}