- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源 - 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调) - Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录 - 门禁:mkdocs build --strict 通过(零警告)
11 KiB
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 直连容器)读回该行——数据真实落库、任何重启后进程可见;断言密码为$2bcrypt 且不含明文。 - 唯一约束生效:重复用户名 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. 遗留问题
/internal/**仍无访问控制(审计 B2 之一):本波未动,属/internal鉴权工单;UserProfile.phone仍会经该接口返回。- token 仍为不可验证随机串;
AuthTokenResponse.expiresAt仍是无时区LocalDateTime——两者随下一波 JWT/refresh session 工单处理(identity.auth_sessions表已 baseline 就绪)。 - 登录失败限制未实现:
user_credentials.failed_login_count/locked_until列已就位,逻辑留待 JWT 波或其后。 - ErrorDecoder 兜底语义:下游返回非 Patbond 信封时统一报 50300/503(含理论上的非信封 4xx);两端
GlobalExceptionHandler高度相似但因 common 是纯契约模块(无 spring-web)而各自持有,将来若出现第三个服务可考虑抽 patbond-web-starter。 - 文档 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 账号导入开发库,两种前缀会并存(无功能影响)。 - 本机
~/.m2旧 common 快照:本波clean test走 reactor 不受影响,但单模块spring-boot:run前仍需./mvnw -pl patbond-common install(README 已写明,与上波结论一致)。