Files
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

117 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 已写明,与上波结论一致)。