Files
patbond-doc/docs/development/iterations/iteration-1/10-backend-persistence-report.md
T
lixi 209021e7d2 docs: 迁入第一迭代过程报告并建立进展看板
- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源
- 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调)
- Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录
- 门禁:mkdocs build --strict 通过(零警告)
2026-09-04 10:45:05 +08:00

11 KiB
Raw Blame History

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+citextschema 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.javaJdbcClient 直写 identity.users + identity.user_credentials,软删行(deleted_at IS NULL)对所有读不可见;created_at 由 DB 默认值产生并 RETURNING 回带。
  • UserService 重写:createUser 单事务插两表;密码 bcryptBCryptPasswordEncoder);用户名/手机号唯一性完全依赖数据库约束,捕获 DuplicateKeyException 后按违反的约束名(users_username_key / uq_users_phone)翻译为 409 业务码;verifyPassword 对不存在的用户也做一次哑 hash 比对,避免时间侧信道暴露账号存在性。
  • phone 校验对齐 DB ck_users_phoneCreateUserRequestcommon)与 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(无时区) OffsetDateTimeISO 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-userpatbond-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-jdbcflyway-core+flyway-database-postgresqlpostgresql 驱动;测试侧 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 对 platformregions 外键、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 已写明,与上波结论一致)。