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:
@@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/users")
|
||||
public class UserController {
|
||||
@@ -35,7 +37,7 @@ public class UserController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<UserProfile> getById(@PathVariable Long id) {
|
||||
public ApiResponse<UserProfile> getById(@PathVariable UUID 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;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.user.CreateUserRequest;
|
||||
import com.patbond.patbond.common.user.UserProfile;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import com.patbond.patbond.user.repository.UserRepository;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final AtomicLong idGenerator = new AtomicLong(1);
|
||||
private final Map<Long, UserRecord> usersById = new ConcurrentHashMap<>();
|
||||
private final Map<String, UserRecord> usersByUsername = new ConcurrentHashMap<>();
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
public synchronized UserProfile createUser(CreateUserRequest request) {
|
||||
String username = request.getUsername().trim();
|
||||
if (usersByUsername.containsKey(username)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "用户名已存在");
|
||||
}
|
||||
/**
|
||||
* Matched against when the username does not exist, so the response time
|
||||
* of verifyPassword does not reveal whether an account exists.
|
||||
*/
|
||||
private final String unknownUserHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||
|
||||
UserRecord user = new UserRecord(
|
||||
idGenerator.getAndIncrement(),
|
||||
username,
|
||||
passwordEncoder.encode(request.getPassword()),
|
||||
normalizeBlank(request.getNickname()),
|
||||
normalizeBlank(request.getPhone()),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
usersById.put(user.getId(), user);
|
||||
usersByUsername.put(user.getUsername(), user);
|
||||
return toProfile(user);
|
||||
public UserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserProfile createUser(CreateUserRequest request) {
|
||||
UUID id = UuidV7.generate();
|
||||
String username = request.getUsername().trim();
|
||||
String nickname = normalizeBlank(request.getNickname());
|
||||
String phone = normalizeBlank(request.getPhone());
|
||||
|
||||
OffsetDateTime createdAt;
|
||||
try {
|
||||
createdAt = userRepository.insertUser(id, username, nickname, phone);
|
||||
userRepository.insertCredential(id, passwordEncoder.encode(request.getPassword()));
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw translateDuplicate(e);
|
||||
}
|
||||
return new UserProfile(id, username, nickname, phone, createdAt);
|
||||
}
|
||||
|
||||
public VerifyPasswordResponse verifyPassword(VerifyPasswordRequest request) {
|
||||
UserRecord user = usersByUsername.get(request.getUsername().trim());
|
||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误");
|
||||
UserRepository.AuthRow auth = userRepository.findAuthByUsername(request.getUsername().trim())
|
||||
.orElse(null);
|
||||
String storedHash = auth == null ? unknownUserHash : auth.passwordHash();
|
||||
if (!passwordEncoder.matches(request.getPassword(), storedHash) || auth == null) {
|
||||
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
|
||||
}
|
||||
return new VerifyPasswordResponse(user.getId(), user.getUsername(), user.getNickname());
|
||||
return new VerifyPasswordResponse(auth.id(), auth.username(), auth.nickname());
|
||||
}
|
||||
|
||||
public UserProfile getById(Long id) {
|
||||
UserRecord user = usersById.get(id);
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
return toProfile(user);
|
||||
public UserProfile getById(UUID id) {
|
||||
return userRepository.findById(id)
|
||||
.map(UserService::toProfile)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
}
|
||||
|
||||
public UserProfile getByUsername(String username) {
|
||||
UserRecord user = usersByUsername.get(username.trim());
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
return toProfile(user);
|
||||
return userRepository.findByUsername(username.trim())
|
||||
.map(UserService::toProfile)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
}
|
||||
|
||||
private UserProfile toProfile(UserRecord user) {
|
||||
return new UserProfile(user.getId(), user.getUsername(), user.getNickname(), user.getPhone(), user.getCreatedAt());
|
||||
private static UserProfile toProfile(UserRepository.UserRow row) {
|
||||
return new UserProfile(row.id(), row.username(), row.nickname(), row.phone(), row.createdAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniqueness is decided by the database constraints (partial unique
|
||||
* indexes cannot be replicated reliably in application checks); the
|
||||
* violated constraint name selects the business error.
|
||||
*/
|
||||
private BusinessException translateDuplicate(DuplicateKeyException e) {
|
||||
String message = e.getMessage() == null ? "" : e.getMessage();
|
||||
if (message.contains("users_username_key")) {
|
||||
return new BusinessException(ErrorCode.USERNAME_EXISTS);
|
||||
}
|
||||
if (message.contains("uq_users_phone")) {
|
||||
return new BusinessException(ErrorCode.PHONE_EXISTS);
|
||||
}
|
||||
return new BusinessException(ErrorCode.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
private String normalizeBlank(String value) {
|
||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static class UserRecord {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String passwordHash;
|
||||
private final String nickname;
|
||||
private final String phone;
|
||||
private final LocalDateTime createdAt;
|
||||
|
||||
private UserRecord(Long id, String username, String passwordHash, String nickname, String phone,
|
||||
LocalDateTime createdAt) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.passwordHash = passwordHash;
|
||||
this.nickname = nickname;
|
||||
this.phone = phone;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
private Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
private String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
private String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
private String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
private String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
private LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
application:
|
||||
name: patbond-user
|
||||
datasource:
|
||||
url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond}
|
||||
username: ${PATBOND_DB_USER:patbond}
|
||||
password: ${PATBOND_DB_PASSWORD:patbond}
|
||||
flyway:
|
||||
locations: classpath:db/migration
|
||||
|
||||
# Development seed data (regions reference rows) is opt-in. To load it,
|
||||
# activate a dev profile that widens the Flyway locations:
|
||||
#
|
||||
# ---
|
||||
# spring:
|
||||
# config:
|
||||
# activate:
|
||||
# on-profile: dev
|
||||
# flyway:
|
||||
# locations: classpath:db/migration,classpath:db/dev
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user