# 10 后端持久化与统一异常报告(第一迭代·第二波) - 执行人:Senior Developer - 日期:2026-09-04 - 仓库:`patbond-api`(改动全部留在工作区,未提交) - 范围:开发计划第 8 节任务 1、2、3 —— Flyway baseline、用户 UUID 持久化、统一异常响应,以及配套 Testcontainers 集成测试。不含 JWT/refresh token、OpenAPI、`/internal` 鉴权、Flutter(后续工单)。 --- ## 1. Flyway baseline(任务 1) ### 迁移文件清单 | 文件 | 说明 | | --- | --- | | `patbond-user/src/main/resources/db/migration/V1__identity_media_baseline.sql` | 版本化 baseline:扩展 `pgcrypto`+`citext`;schema `platform`(仅硬依赖:`set_updated_at()` 函数 + `regions` 表)、`identity` 全部 5 表(users、user_credentials、auth_sessions、user_addresses、user_preferences)、`media.assets`;全部 CHECK/UNIQUE 约束、部分索引、跨 schema 外键、updated_at 触发器,逐条对齐 bootstrap SQL | | `patbond-user/src/main/resources/db/dev/afterMigrate__dev_seed.sql` | 开发种子(Flyway afterMigrate 回调),**默认不执行**——仅当 dev profile 把 `classpath:db/dev` 加入 `spring.flyway.locations` 时加载;内容只有 6 条 `platform.regions` 参考数据(幂等 `ON CONFLICT DO NOTHING`) | 要点: - 跨 schema 外键 `identity.users.avatar_asset_id → media.assets(id)` 按任务指示两 schema 一起 baseline 后原样保留。 - `identity.user_addresses`/`user_preferences` 外键引用 `platform.regions`,故 platform 以"最小硬依赖"方式进入 baseline(函数 + regions 表),`distance_km`、notifications、outbox 等均未纳入。 - **无任何 fixture 账号/凭证/预置会话**:`grep -c "Patbond@123"` 对两个 SQL 文件均为 0(已实测)。bootstrap 里的 demo_user 三账号、预置 auth_session 刻意不迁移——开发环境请走真实注册接口造数。 - `auth_sessions` 表结构已就位但本波无代码读写,供下一波 refresh session 使用。 ## 2. UUID 持久化(任务 2) `patbond-user` 从 ConcurrentHashMap 全面迁移到 PostgreSQL: - 新增 `user/support/UuidV7.java`:应用层 RFC 9562 UUIDv7 生成器(48 位毫秒时间戳 + 74 随机位),DB 的 `gen_random_uuid()` 保留为兜底默认值。 - 新增 `user/repository/UserRepository.java`:JdbcClient 直写 `identity.users` + `identity.user_credentials`,软删行(`deleted_at IS NULL`)对所有读不可见;`created_at` 由 DB 默认值产生并 RETURNING 回带。 - `UserService` 重写:`createUser` 单事务插两表;密码 bcrypt(`BCryptPasswordEncoder`);用户名/手机号唯一性**完全依赖数据库约束**,捕获 `DuplicateKeyException` 后按违反的约束名(`users_username_key` / `uq_users_phone`)翻译为 409 业务码;`verifyPassword` 对不存在的用户也做一次哑 hash 比对,避免时间侧信道暴露账号存在性。 - phone 校验对齐 DB `ck_users_phone`:`CreateUserRequest`(common)与 `RegisterRequest`(auth) 均改为 `@Pattern("^\\+[1-9][0-9]{7,14}$")`(E.164),审计 B4 关闭。 ### ID 契约变更点(供 OpenAPI/Flutter 工单使用) | 位置 | 旧 | 新 | | --- | --- | --- | | `UserProfile.id` | number (Long 自增) | **UUID 字符串**(UUIDv7) | | `VerifyPasswordResponse.userId` | number | UUID 字符串 | | `AuthTokenResponse.userId`(/auth/register、/auth/login 响应) | number | UUID 字符串 | | `GET /internal/users/{id}` 路径参数 | Long | UUID;格式非法 → 400 + 40000 | | `UserProfile.createdAt` | `LocalDateTime`(无时区) | `OffsetDateTime`,ISO 8601 带偏移(对齐"timestamptz + ISO 8601 传输") | | `phone`(注册/创建用户入参) | 任意 ≤20 字符 | 必须 E.164(`+8613800138000`),或不传 | | `AuthTokenResponse.expiresAt` | `LocalDateTime` | **未改**——随下一波 token 重构一并处理(遗留 §5.2) | ## 3. 统一异常响应(任务 3) 新增 `patbond-common/error/ErrorCode.java`(稳定业务码枚举)+ `BusinessException.java`(携带 code/httpStatus/message,可承载下游原样转发的任意码);`patbond-user` 与 `patbond-auth` 各一个 `GlobalExceptionHandler`(`@RestControllerAdvice`),响应维持 `{code, message, data}` 信封。 ### 错误码表 | 业务码 | HTTP | 场景 | | --- | --- | --- | | 0 | 200 | 成功 | | 40000 | 400 | 参数校验失败(含 JSON 不可解析、路径 UUID 非法;message 为首个字段错误) | | 40100 | 401 | 用户名或密码错误 | | 40400 | 404 | 用户不存在 / 资源不存在 | | 40900 | 409 | 用户名已存在(citext,大小写不敏感) | | 40901 | 409 | 手机号已被使用 | | 50000 | 500 | 服务器内部错误(记日志,不外泄内部信息) | | 50300 | 503 | 依赖服务暂不可用(Feign 传输层失败 / 下游响应非信封格式) | ### 错误码折叠修复(审计 M1) - auth 新增 `config/ApiErrorDecoder.java`(经 `FeignConfig` 注册为全局 ErrorDecoder):下游非 2xx 时解析 `{code, message}` 信封,**以原业务码 + 原 HTTP 状态**重新抛出 `BusinessException`——重复用户名注册经 auth 仍是 409/40900,错误密码登录仍是 401/40100,不再折叠为 400/500。 - 信封解析失败(HTML 网关页、空 body 等)→ 50300/503;连接被拒等传输层 `FeignException` 由 auth 的 handler 兜为 503,不再以 500 栈溢出到客户端。 - `AuthService.requireData` 的"一律 400"折叠逻辑废除,仅作为 2xx-但信封异常的防御性兜底(→ 50000)。 - 上一波钉现状的 `loginPropagatesFeignExceptionUnhandled` 测试按新契约改写(见 §4)。 ## 4. 测试与验收执行记录(任务 4) 依赖:`spring-boot-starter-jdbc`、`flyway-core`+`flyway-database-postgresql`、`postgresql` 驱动;测试侧 `spring-boot-testcontainers` + Testcontainers `postgresql`/`junit-jupiter`(版本均由 Boot 3.5.16 BOM 管理)。user 模块所有 `@SpringBootTest` 通过 `TestcontainersConfiguration`(`@ServiceConnection`)对接一次性 postgres:16 容器,**未连接任何本地 PostgreSQL**。 `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 实际输出(关键行): ``` tc.postgres:16 : Container postgres:16 started in PT1.166108936S o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "1 - identity media baseline" o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.071s) [INFO] Tests run: 3, ... -- in com.patbond.patbond.common.response.ApiResponseTest [INFO] Tests run: 5, ... -- in com.patbond.patbond.user.persistence.UserPersistenceIntegrationTest [INFO] Tests run: 1, ... -- in com.patbond.patbond.user.UserApplicationTests [INFO] Tests run: 13, ... -- in com.patbond.patbond.user.controller.UserControllerTest [INFO] Tests run: 3, ... -- in com.patbond.patbond.user.support.UuidV7Test [INFO] Tests run: 8, ... -- in com.patbond.patbond.auth.controller.AuthControllerTest [INFO] Tests run: 3, ... -- in com.patbond.patbond.auth.config.ApiErrorDecoderTest [INFO] Tests run: 1, ... -- in com.patbond.patbond.auth.AuthApplicationTests [INFO] patbond-common ..................................... SUCCESS [INFO] patbond-user ....................................... SUCCESS [ 11.361 s] [INFO] patbond-auth ....................................... SUCCESS [INFO] BUILD SUCCESS ``` 合计 **37 个测试(21 → 37),0 失败 0 错误**;Flyway V1 在两个干净 postgres:16 容器上各自成功执行(user 模块两个测试上下文各起一容器),验证后由 Testcontainers/ryuk 自动回收,`docker ps` 无遗留容器、无遗留后台进程。 覆盖对照验收要求: - **迁移在干净 postgres:16 可执行**:每个测试上下文启动即全量跑 V1(见上 Flyway 日志);`flywayBaselineAppliedOnCleanPostgres16` 断言 history 表 V1 成功 + 7 张目标表存在。 - **持久化/重启语义**:`registeredUserIsDurablyStoredWithBcryptHash` 经 Service 注册后,用**全新原生 JDBC 连接**(DriverManager 直连容器)读回该行——数据真实落库、任何重启后进程可见;断言密码为 `$2` bcrypt 且不含明文。 - **唯一约束生效**:重复用户名 409/40900(含大小写不敏感 citext 用例)、重复手机号 409/40901,均由 DB 约束触发。 - **接口层**:注册成功(UUID 断言)/参数错误 400/40000、非 E.164 手机号 400、错误密码 401/40100、未知用户 401、getById 404/40400、非法 UUID 400。 - **auth 转发不折叠**:mock UserClient 抛 `BusinessException`(即 ErrorDecoder 的产物)→ 409/401 原样透出;ErrorDecoder 本体 3 个单元测试(信封透传/非信封 body/空 body);传输层 FeignException → 503/50300。 - **DB 兜底校验**:绕过 DTO 直插非 E.164 手机号被 `ck_users_phone` 拒绝(证明 DTO 与 CHECK 对齐且 DB 仍兜底)。 其余改动:`application.yml`(本地,仍 git-ignored)与 `application.yml.sample` 增加 datasource/flyway 配置及 dev-seed 开启方式说明;README 更新(Docker/Testcontainers 要求、DB 环境变量表、持久化现状注记)。 ## 5. 遗留问题 1. **`/internal/**` 仍无访问控制**(审计 B2 之一):本波未动,属 `/internal` 鉴权工单;`UserProfile.phone` 仍会经该接口返回。 2. **token 仍为不可验证随机串**;`AuthTokenResponse.expiresAt` 仍是无时区 `LocalDateTime`——两者随下一波 JWT/refresh session 工单处理(`identity.auth_sessions` 表已 baseline 就绪)。 3. **登录失败限制未实现**:`user_credentials.failed_login_count/locked_until` 列已就位,逻辑留待 JWT 波或其后。 4. **ErrorDecoder 兜底语义**:下游返回非 Patbond 信封时统一报 50300/503(含理论上的非信封 4xx);两端 `GlobalExceptionHandler` 高度相似但因 common 是纯契约模块(无 spring-web)而各自持有,将来若出现第三个服务可考虑抽 patbond-web-starter。 5. **文档 SQL 观察**(不改 patbond-doc,仅记录):a) bootstrap 声称分阶段"identity/media → …",但 identity 对 `platform`(regions 外键、set_updated_at 函数)有硬依赖,任何按域拆分的 baseline 都必须先带上 platform 最小集——本波已如此处理,后续 pet_health 等 baseline 同理;b) fixture 凭证 hash 为 `$2y$`(PHP 风格 bcrypt),Spring 可校验但应用新产 hash 为 `$2a$`,如果未来有人把 fixture 账号导入开发库,两种前缀会并存(无功能影响)。 6. **本机 `~/.m2` 旧 common 快照**:本波 `clean test` 走 reactor 不受影响,但单模块 `spring-boot:run` 前仍需 `./mvnw -pl patbond-common install`(README 已写明,与上波结论一致)。