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,178 @@
# Patbond 第一迭代技术评估(Dev
> 作者:Senior Developer
> 日期:2026-09-03
> 范围:第一迭代「真实登录纵切」(development-plan.md 第 8 节 8 项任务)的实现方案、阻塞点与技术选型建议
> 依据:development-plan.md(重点第 4、5、6、8、11 节)、patbond-api 三模块源码、patbond_postgresql.sql、patbond-flutter/lib 与 pubspec.yaml
---
## 1. 现状盘点(实际读码结论)
### 1.1 patbond-api
| 项 | 现状 | 关键文件 |
| --- | --- | --- |
| 框架 | Spring Boot 2.7.18 + Spring Cloud 2021.0.9 + Spring Cloud Alibaba 2021.0.6.0Java 17 | `patbond-api/pom.xml` |
| 用户存储 | `ConcurrentHashMap` 内存表,`AtomicLong` 自增 Long ID,重启即失;无任何 JDBC/JPA/Flyway 依赖 | `patbond-user/.../service/UserService.java` |
| 密码 | BCrypt`spring-security-crypto`),这是唯一可直接保留的安全实现 | 同上 |
| Token | `UUID.randomUUID()` 去掉横线的随机串,服务端不存储、不可验证、不可刷新、不可撤销;响应无 refreshToken`expiresAt``LocalDateTime`(无时区,违反第 4.3/6.1 节 ISO 8601 约定) | `patbond-auth/.../service/AuthService.java``dto/AuthTokenResponse.java` |
| 服务间调用 | auth 经 Feign + Nacos 发现调用 user 的 `/internal/users``/internal/users/verify-password`,**无任何服务间认证**(风险 11.3) | `patbond-auth/.../client/UserClient.java``patbond-user/.../controller/UserController.java` |
| 路由 | `/auth/*``/internal/users/*`,无 `/api/v1` 前缀(不符第 6 节) | 两个 Controller |
| 错误处理 | 直接抛 `ResponseStatusException`,返回 Spring 默认错误体;auth 的 `requireData()` 把 user 服务的 401/409 一律折叠成 400 | `AuthService.java:58-64` |
| 配置 | 仅 `application.yml.sample`,干净检出无法启动(风险 11.5);Nacos 是硬前置(`spring.config.import: nacos:`,靠 `optional:``fail-fast:false` 缓解) | 两个 `application.yml.sample` |
| common 模块 | 携带 `starter-web``starter-amqp``openfeign``loadbalancer``nacos-discovery``nacos-config`、hutool 全量传递依赖——user 模块被动引入 RabbitMQ 和 Feign**直接违反第 4.1 节约束** | `patbond-common/pom.xml` |
| 契约 DTO | `UserProfile.id``VerifyPasswordResponse.userId``AuthTokenResponse.userId` 均为 `Long``UserController` 路径参数 `Long id`;时间均为 `LocalDateTime` | `patbond-common/.../user/*.java` |
| 校验 | DTO 上 username 3-32、password 6-64 与 DB `ck_users_username` 一致;**phone 仅限长 ≤20**,而 DB 要求 E.164`^\+[1-9][0-9]{7,14}$`varchar(16))——现有校验通过的手机号会被数据库拒绝 | `RegisterRequest.java``CreateUserRequest.java` vs SQL `ck_users_phone` |
| 测试 | 零测试代码 | — |
### 1.2 patbond-flutter
- `pubspec.yaml` 依赖只有 `cupertino_icons``shared_preferences`^2.5.4),Dart SDK `^3.12.2`。**没有 dio/http、没有 secure storage、没有状态管理库**`AppState` 是手工 `ChangeNotifier`,经构造函数逐层传递)。
- `lib/state/app_state.dart`:单一 God-state,宠物/疫苗/帖子/天气全部 JSON 序列化进 `SharedPreferences`,与第 4.2 节目标架构(feature 拆分 + secure storage)完全不同。
- `lib/models/models.dart`:大量展示型字符串字段——`PostModel.time`"2小时前")、`ServiceProviderModel.distance`"1.2km")、`PetProfile.birthday`/`VaccineItem.date` 为 String(风险 11.4)。第一迭代只需动 auth,但新建的 auth 模型必须按事实字段(`DateTime`/UUID 字符串)建模,不复用这套模式。
- `app.dart` 直接 `home: MainShellPage(...)`,无路由守卫、无登录页;`test/` 仅一个 widget_test。
### 1.3 patbond-doc / 数据库
- `patbond_postgresql.sql`1950 行):7 schema、36 表,事务包裹的一次性 bootstrap;扩展依赖 `pgcrypto``citext``pg_trgm``btree_gist`
- 第一迭代关心的部分:
- `identity.users`uuid PK、`username citext UNIQUE``status``version` 乐观锁、软删;`phone_e164`/`email` 为部分唯一索引(`WHERE status <> 'deleted'`)——**唯一性无法只靠应用层判断,必须靠 DB 约束 + 冲突捕获**。
- `identity.user_credentials`:与 users 1:1,含 `failed_login_count``failure_window_started_at``locked_until`——登录失败限制(M1 要求)的落库结构已备好。
- `identity.auth_sessions`refresh 会话表已完整设计——`token_family_id`(家族撤销/重用检测)、`refresh_token_hash bytea` 且约束 `octet_length=32`(即 **SHA-256 摘要**,不是明文)、`access_token_jti``rotated_at`/`replaced_by_session_id` 轮换链、`revoked_at`。**任务 4 的数据模型不需要设计,照实现即可。**
- 跨 schema 依赖:`identity.users.avatar_asset_id -> media.assets`(文件尾部 ALTER TABLE 补加 FK),`identity.user_addresses/user_preferences -> platform.regions`。**Flyway baseline 必须同时含 platform.regions、identity 全部、media.assets,否则建不起来。**
- 种子数据集中在 1272 行之后(含开发用户、开发会话、演示预约),与结构部分天然可分割。
---
## 2. 必须先解决的阻塞点(按影响排序)
### B1. Long ID vs UUID —— 对外契约级阻塞(风险 11.1)
`Long` 贯穿 `UserProfile``VerifyPasswordResponse``AuthTokenResponse``UserController.getById(Long)` 和整个内存实现。数据库全部是 uuid。这不是重构项而是**契约变更**:OpenAPI(任务 6)和 Flutter 客户端(任务 7)都以最终契约为输入,所以 UUID 切换必须发生在任务 2,且在任何客户端代码开工之前冻结。对外 JSON 一律 UUID 字符串(第 6.1 节)。
### B2. Token 模型整体不可用 + auth_sessions 归属未定(风险 11.2
随机串 token 无法支撑 M1 的任何验收标准(可验证、可刷新、可撤销)。`AuthTokenResponse` 还缺 `refreshToken`/`expiresIn`,时间类型错误。同时有一个**必须先拍板的架构决策**:`identity` schema 的唯一数据所有者是 patbond-user(第 4.1 节),但发 token 的是 patbond-auth——`auth_sessions` 的读写归谁?不定下来任务 4 没法动工。我的建议见 §3 任务 4。
### B3. 干净检出不可启动、不可测试(风险 11.5 + common 依赖污染)
三件事叠加:`application.yml` 只有 sample`spring.config.import` 硬指 Nacos`patbond-common` 把 web/amqp/feign/nacos 塞给所有下游。后果是集成测试(任务 5)在 CI 里根本跑不起来(测试上下文会尝试连 Nacos/RabbitMQ)。需要:提交带环境变量占位的默认 `application.yml`(敏感值走 `PATBOND_DB_*`,第 5.3 节);common 瘦身为纯 DTO/契约(web/validation-api 之外全部下放到用的模块);test profile 关闭 nacos discovery/config、Feign 走静态 URL。
### B4. phone 校验与 DB 约束冲突
现有 `@Size(max=20)` 会放行 `13800138000` 这类值,落库时被 `ck_users_phone`(E.164)拒绝,用户看到的是 500 而不是 400。任务 3 必须统一:要么客户端只收 E.164,要么服务端把 CN 手机号规范化为 `+86...` 再入库。
### B5. bootstrap SQL 与 Flyway 的一次性冲突(风险 11.7)
本地库若已执行过 bootstrap,直接上 Flyway 会 checksum/对象冲突。按第 4.3 节:**优先重建开发库**(成本最低,现阶段无真实数据),备选 baseline 对齐。种子里的开发用户/会话绝不能进 V1 迁移。
---
## 3. 第 8 节 8 项任务逐项实现方案
### 任务 1:从 bootstrap SQL 提取 identity/media 的 Flyway baseline
- **涉及现有文件**`patbond-doc/docs/database/patbond_postgresql.sql`(结构源,50-331 行 + 文件尾部 identity 相关 ALTER TABLE)。
- **新建**
- `patbond-user/src/main/resources/db/migration/V1__baseline_platform_identity_media.sql`extensionspgcrypto/citext/btree_gist)、`platform` schema + `platform.regions``identity` 全部 5 张表及索引、`media.assets``users.avatar_asset_id` FK。pg_trgm/pg_trgm 相关索引不在本迭代范围可不带。
- `db/seed/dev-seed.sql``db/migration-dev/R__dev_seed.sql`:开发种子分离,仅在 `dev` profile 通过 `spring.flyway.locations` 追加(第 4.3 节"结构、种子、校验分离")。
- **库**`flyway-core`。注意 Boot 2.7 默认管理 Flyway 8.5.x,对 PostgreSQL 16 的官方支持要 Flyway 9.21+——需要显式 pin 版本并验证与 2.7 自动配置的兼容性。这是升 Boot 3 的加分项之一(Boot 3.x 默认 Flyway 9/10)。
- **配置**`spring.flyway.schemas=platform,identity,media``createSchemas=true``defaultSchema` 明确指定 history 表位置;数据源用 `PATBOND_DB_URL/USERNAME/PASSWORD` 环境变量注入(第 5.3 节),应用账号最小权限。
- **风险对应**:11.7(种子分离)、11.9(跨 schema FK 保留,MVP 共库,第 4.1 节允许)。
### 任务 2patbond-user 接 PostgreSQL RepositoryUUID 用户持久化
- **涉及现有文件**:重写 `UserService.java`(删除内存表和 `AtomicLong`);`UserController.java``Long id``UUID`);common 的 `UserProfile`/`VerifyPasswordResponse``Long``String` UUID`LocalDateTime``Instant`/`OffsetDateTime`);`patbond-user/pom.xml`
- **新建**`user/domain/UserEntity` + `UserCredentialEntity`(映射 `identity.users``identity.user_credentials`)、`user/repository/``user/config/`(数据源、时区 UTC)。
- **库选型**
- 持久化建议 **Spring Data JDBC**(或退一步 `JdbcTemplate`):schema 是 DB-first 且已冻结(citext、部分唯一索引、timestamptz),不需要 Hibernate 的 DDL 能力,JPA 的 citext/软删/version 映射反而添乱。若团队 JPA 熟练度高也可用 JPA,但必须 `ddl-auto=none`
- UUIDv7 用 `com.fasterxml.uuid:java-uuid-generator``Generators.timeBasedEpochGenerator()`)应用层生成,DB `gen_random_uuid()` 兜底(第 4.3 节原文要求)。
- `postgresql` JDBC driver、HikariCPstarter 自带)。**这些依赖只加在 patbond-user,不进 common。**
- **要点**:用户名唯一性放弃 `containsKey` 预检,改为依赖 citext UNIQUE + 捕获 `DuplicateKeyException` → 409(并发下预检不可靠);写入同事务落 users + user_credentials 两表;`version` 字段随实体带出为后续乐观锁做准备。
- **风险对应**11.1(UUID 统一)、11.5(配套提交默认 application.yml)。
### 任务 3:统一异常响应 + 用户名/手机号数据库一致校验
- **涉及现有文件**`ApiResponse.java`(补错误码语义);两个 DTO 的 phone 校验;`AuthService.requireData()`(废除"一律 400"的折叠逻辑)。
- **新建**
- common`ErrorCode` 枚举(稳定业务码,如 `USER_NAME_TAKEN``INVALID_CREDENTIALS``TOKEN_EXPIRED`)+ 统一错误体约定——common 只放契约类型,符合第 4.1 节。
- 各服务:`GlobalExceptionHandler``@RestControllerAdvice`),映射 `MethodArgumentNotValidException`→400、重复键→409、`ResponseStatusException` 透传、兜底 500 不泄内部信息;auth 端为 Feign 加 `ErrorDecoder`,把 user 服务的错误码和 HTTP 状态原样向客户端传递(第 6.1 节"正确状态码 + 稳定业务码")。
- 日志脱敏:异常日志不落密码/token/手机号全文(第 6.1 节)。
- **校验统一**phone 加 `@Pattern(regexp="^\\+[1-9][0-9]{7,14}$")` 或注册流程规范化 CN 号码为 E.164(需产品确认输入形态,建议后者);username 沿用 3-32 并 trimnickname 1-32。所有约束以 SQL CHECK 为准绳(第 1 节冲突处理顺序第 2 条)。
### 任务 4:可校验 access token + refresh session
- **架构决策(先拍板)**`identity.auth_sessions` 归 patbond-user 所有(它是 identity schema 唯一所有者,第 4.1 节)。**建议:登录/注册/刷新/撤销的会话逻辑全部下沉到 patbond-user**patbond-auth 保留为面向客户端的薄入口(校验参数、编排、签发 JWT)。备选方案是 user 暴露 `/internal/sessions` CRUD 给 auth 编排,但两跳事务边界更碎,MVP 不值得。
- **Token 方案**
- **Access tokenJWT**,建议 `jjwt 0.12.x``nimbus-jose-jwt`(比引入整套 spring-security-oauth2 轻)。claims`sub`=user UUID、`jti`(回写 `auth_sessions.access_token_jti`)、`iat/exp`15-30 分钟)、`sid`=session id。签名算法:MVP 单签发方可用 HS256(密钥走环境变量),但 M2 起 pet/community 模块都要本地验签,**建议直接上 RS256/EdDSA 非对称**,公钥随 common 契约或 JWKS 端点分发,避免以后共享密钥扩散。
- **Refresh token:不透明随机串**256-bit `SecureRandom`base64url),服务端只存 SHA-256 摘要进 `refresh_token_hash`(正好满足 `octet_length=32` 约束)。轮换:每次 refresh 新建 session 行、旧行写 `revoked_at + rotated_at + replaced_by_session_id`;同 `token_family_id` 检测重用(旧 refresh 被再次使用 → 撤销整个家族),schema 已为此建好索引。
- 有效期(access 15-30min / refresh 14-30 天 / 多设备策略)是第 12 节待确认事项,实现上做成配置项,不写死。
- **接口变更**:统一 `/api/v1/auth/{register,login,refresh,logout}`(第 6.2 节);`AuthTokenResponse` 增加 `refreshToken``expiresIn`(秒),`userId` 改 UUID 字符串,时间字段 ISO 8601 UTC。
- **/internal 保护(风险 11.3)**:第一迭代最小方案——环境变量注入的静态 service token`/internal/**``OncePerRequestFilter` 校验 + Feign `RequestInterceptor` 注入;网关/端口层面不对外暴露 internal 路由。留 ADR 记录后续换 mTLS 或 token exchange。
- **登录失败限制(M1 要求)**:verify 失败时原子更新 `failed_login_count/failure_window_started_at`,超阈值写 `locked_until`,命中返回 423/固定业务码——列全在 `user_credentials` 里。
- **风险对应**11.2、11.3。
### 任务 5:注册/登录/刷新/退出/鉴权集成测试
- **库**`spring-boot-starter-test` + **Testcontainers**`org.testcontainers:postgresql` 1.19+)。注意 Boot 2.7 没有 `@ServiceConnection`3.1+ 特性),用 `@DynamicPropertySource` 注入容器 URL。
- **前置**B3 必须先解决——test profile 关闭 nacos`spring.cloud.nacos.discovery.enabled=false` 等)、Feign 改静态 URL 或 mock,否则 `@SpringBootTest` 起不来。
- **分层**
- patbond-userFlyway 迁移可在空库执行 + Repository 落库/唯一冲突/失败锁定测试(真实 PG 容器,第 9 节要求)。
- patbond-authJWT 签发/过期/篡改验证单测;Controller 层 mock UserClient。
- 纵切集成:register → login → 携带 token 访问受保护端点 → refresh(旧 refresh 复用被拒)→ logoutsession 撤销后 refresh 不可用)——对应 M1 全部验收标准。
- **覆盖门禁**:每接口至少成功/参数错/401/409/重放五类(第 9 节)。
### 任务 6OpenAPI + Flutter API Client
- **建议契约先行(design-first**:手写 `patbond-doc/docs/api/openapi.yaml`OpenAPI 3.0),只含第一批 auth/me 端点,团队评审后冻结,再实现两端。代码生成文档的备选是 springdoc——注意 Boot 2.7 只能用 springdoc 1.7.x2.x 需要 Boot 3),又一个升级加分项。
- **Flutter 客户端**:端点只有 5-6 个,**建议手写 dio client + 手写 DTO`json_serializable` 可选)**,不引 openapi-generatordart-dio 生成器的产物风格重、定制成本高,等接口上量再评估)。契约一致性靠任务 5 的契约测试兜底。
- **新建**`lib/core/network/api_client.dart`dio 实例、baseUrl 环境区分、`ApiResponse<T>` 信封解包、错误码映射)。
### 任务 7Flutter 登录页、安全 token 存储、登录态恢复
- **新增依赖**`dio``flutter_secure_storage`(第 4.2 节"access token 仅保存在安全存储";注意 Linux 桌面调试需要 libsecret,团队若在桌面跑 demo 要提前确认)、`provider`(把手工传递的 ChangeNotifier 挂到树上,为 feature 拆分铺路;不建议本迭代就上 riverpod/bloc 全家桶)。
- **新建**
- `lib/features/auth/``login_page.dart``register_page.dart``auth_controller.dart``AuthState`: unknown/unauthenticated/authenticated)。
- `lib/data/repositories/auth_repository.dart``lib/data/dto/`(auth DTO,字段全部事实类型:UUID String、`DateTime`)。
- `lib/core/storage/token_storage.dart`secure storage 读写 access/refresh。
- dio `AuthInterceptor`:注入 Bearer401 时单飞(single-flightrefresh——并发请求排队等同一次刷新,刷新失败清 token 回登录页。
- **改动现有文件**`app.dart` 由 auth 状态决定 `MainShellPage` 还是 `LoginPage`(启动时读 secure storage → 调 `/api/v1/me` 校验 → 失败尝试 refresh);`AppState` 本迭代**不动**其宠物/帖子逻辑,只是不再是唯一状态入口(第 4.2 节的完整拆分留给 M2 逐页替换)。
- `SharedPreferences` 仅保留主题/引导(第 4.2 节),token 绝不落入。
- **风险对应**:11.4(新模型不复制展示字符串反模式)。
### 任务 8:端到端用例(注册 → 登录 → 获取当前用户 → 退出)
- **后端 E2E**(CI 必跑):任务 5 的纵切集成测试天然覆盖,作为门禁。
- **客户端 E2E**`integration_test` 包写一条 happy path(输入注册→进主页→退出回登录页),本地对着 docker-compose 起的后端跑;进 CI 依赖 M0 的编排产物,第一迭代可先手动执行 + 记录在测试文档。
- **前置**:需要一份最小 `docker-compose.yml`PostgreSQL + 两个服务;若采纳下面的建议甚至不需要 Nacos),属于 M0 范畴但被本任务依赖。
---
## 4. Spring Boot 2.7 vs Boot 3:建议
**建议:先升 Boot 3,再写第一迭代的业务代码。** 放在 M0/任务 1 之前,时间盒 1-2 天。
理由:
1. **迁移成本此刻处于历史最低点。** 真实业务代码不到 10 个类,javax→jakarta 只影响几处 validation import;没有任何持久化、安全、测试代码需要迁移。第一迭代恰恰要新写全部这些代码——JWT/资源服务器、Flyway、Testcontainers、springdoc、异常处理——现在按 2.7 写,就是主动制造一批"将来必须重写"的存量。
2. **2.7 与 Spring Cloud 2021 均已 EOL**(2.7.18 是最终版,OSS 安全补丁 2023 年底已停)。对一个要做真实凭证和会话的身份服务,跑在无补丁框架上是不可辩护的(风险 11.8 的答案本身就在第一迭代的安全语境里)。
3. **本迭代的选型在 2.7 上处处降级**Flyway 对 PG16 要手工 pin 9.21+、springdoc 只能停在 1.7、Testcontainers 没有 `@ServiceConnection`、jakarta 生态的新版本库逐个要挑旧 artifact。
4. **唯一的实质阻力是 Spring Cloud Alibaba/Nacos**Boot 3 需要 SCA 2022.x/2023.x 配套(可用,但要一次性把三个 BOM 同步升级)。附带建议:MVP 只有两个服务、部署拓扑固定,**可以评估干脆移除 Nacos**Feign 用静态 URL + 环境变量——同时消掉 B3 里配置导入和测试上下文两个痛点;等模块数量上来再引入注册中心也不迟。此项需与团队确认,不阻塞升级本身。
若团队仍决定先交付 MVP 后升级,则必须接受:任务 4/5/6 的产出物在升级时二次返工,且身份服务在无安全补丁的框架上对外——我不推荐。
## 5. 建议实现顺序
```text
0. 决策先行:Boot 3 升级(含是否移除 Nacos)、auth_sessions 归属(建议下沉 patbond-user
1. 工程基线:common 瘦身为纯契约、提交默认 application.ymlenv 注入)、test profile 可离线启动 [解 B3]
2. 任务 1 + 2 + 3Flyway baseline → UUID 持久化 → 统一异常与校验(同 PR 链,先冻结对外契约) [解 B1/B4/B5]
3. 任务 4JWT + refresh 轮换 + /internal 保护 + 登录失败锁定 [解 B2]
4. 任务 5Testcontainers 集成测试(随 2/3/4 增量补,不后置)
5. 任务 6openapi.yaml 评审冻结
6. 任务 7:Flutter 登录纵切(依赖 5 的冻结契约)
7. 任务 8:后端 E2E 进 CI 门禁;Flutter integration_test 本地跑通
```
测试(任务 5)实际应与 2-4 步同 PR 交付,此处单列仅表示依赖关系。