docs: 迁入第一迭代过程报告并建立进展看板

- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源
- 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调)
- Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录
- 门禁:mkdocs build --strict 通过(零警告)
This commit is contained in:
2026-09-04 10:45:05 +08:00
parent 027876ae00
commit 209021e7d2
19 changed files with 3077 additions and 1 deletions
@@ -0,0 +1,116 @@
# 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 已写明,与上波结论一致)。