Files
patbond-doc/docs/development/iterations/iteration-1/02-dev-technical-assessment.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

179 lines
20 KiB
Markdown
Raw 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.
# 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 交付,此处单列仅表示依赖关系。