- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源 - 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调) - Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录 - 门禁:mkdocs build --strict 通过(零警告)
20 KiB
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.0,Java 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:extensions(pgcrypto/citext/btree_gist)、platformschema +platform.regions、identity全部 5 张表及索引、media.assets、users.avatar_asset_idFK。pg_trgm/pg_trgm 相关索引不在本迭代范围可不带。db/seed/dev-seed.sql或db/migration-dev/R__dev_seed.sql:开发种子分离,仅在devprofile 通过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 节允许)。
任务 2:patbond-user 接 PostgreSQL Repository,UUID 用户持久化
- 涉及现有文件:重写
UserService.java(删除内存表和AtomicLong);UserController.java(Long id→UUID);common 的UserProfile/VerifyPasswordResponse(Long→StringUUID,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())应用层生成,DBgen_random_uuid()兜底(第 4.3 节原文要求)。 postgresqlJDBC driver、HikariCP(starter 自带)。这些依赖只加在 patbond-user,不进 common。
- 持久化建议 Spring Data JDBC(或退一步
- 要点:用户名唯一性放弃
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 节)。
- common:
- 校验统一:phone 加
@Pattern(regexp="^\\+[1-9][0-9]{7,14}$")或注册流程规范化 CN 号码为 E.164(需产品确认输入形态,建议后者);username 沿用 3-32 并 trim;nickname 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/sessionsCRUD 给 auth 编排,但两跳事务边界更碎,MVP 不值得。 - Token 方案:
- Access token:JWT,建议
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 节待确认事项,实现上做成配置项,不写死。
- Access token:JWT,建议
- 接口变更:统一
/api/v1/auth/{register,login,refresh,logout}(第 6.2 节);AuthTokenResponse增加refreshToken、expiresIn(秒),userId改 UUID 字符串,时间字段 ISO 8601 UTC。 - /internal 保护(风险 11.3):第一迭代最小方案——环境变量注入的静态 service token,
/internal/**加OncePerRequestFilter校验 + FeignRequestInterceptor注入;网关/端口层面不对外暴露 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:postgresql1.19+)。注意 Boot 2.7 没有@ServiceConnection(3.1+ 特性),用@DynamicPropertySource注入容器 URL。 - 前置:B3 必须先解决——test profile 关闭 nacos(
spring.cloud.nacos.discovery.enabled=false等)、Feign 改静态 URL 或 mock,否则@SpringBootTest起不来。 - 分层:
- patbond-user:Flyway 迁移可在空库执行 + Repository 落库/唯一冲突/失败锁定测试(真实 PG 容器,第 9 节要求)。
- patbond-auth:JWT 签发/过期/篡改验证单测;Controller 层 mock UserClient。
- 纵切集成:register → login → 携带 token 访问受保护端点 → refresh(旧 refresh 复用被拒)→ logout(session 撤销后 refresh 不可用)——对应 M1 全部验收标准。
- 覆盖门禁:每接口至少成功/参数错/401/409/重放五类(第 9 节)。
任务 6:OpenAPI + Flutter API Client
- 建议契约先行(design-first):手写
patbond-doc/docs/api/openapi.yaml(OpenAPI 3.0),只含第一批 auth/me 端点,团队评审后冻结,再实现两端。代码生成文档的备选是 springdoc——注意 Boot 2.7 只能用 springdoc 1.7.x(2.x 需要 Boot 3),又一个升级加分项。 - Flutter 客户端:端点只有 5-6 个,建议手写 dio client + 手写 DTO(
json_serializable可选),不引 openapi-generator(dart-dio 生成器的产物风格重、定制成本高,等接口上量再评估)。契约一致性靠任务 5 的契约测试兜底。 - 新建:
lib/core/network/api_client.dart(dio 实例、baseUrl 环境区分、ApiResponse<T>信封解包、错误码映射)。
任务 7:Flutter 登录页、安全 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:注入 Bearer;401 时单飞(single-flight)refresh——并发请求排队等同一次刷新,刷新失败清 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 天。
理由:
- 迁移成本此刻处于历史最低点。 真实业务代码不到 10 个类,javax→jakarta 只影响几处 validation import;没有任何持久化、安全、测试代码需要迁移。第一迭代恰恰要新写全部这些代码——JWT/资源服务器、Flyway、Testcontainers、springdoc、异常处理——现在按 2.7 写,就是主动制造一批"将来必须重写"的存量。
- 2.7 与 Spring Cloud 2021 均已 EOL(2.7.18 是最终版,OSS 安全补丁 2023 年底已停)。对一个要做真实凭证和会话的身份服务,跑在无补丁框架上是不可辩护的(风险 11.8 的答案本身就在第一迭代的安全语境里)。
- 本迭代的选型在 2.7 上处处降级:Flyway 对 PG16 要手工 pin 9.21+、springdoc 只能停在 1.7、Testcontainers 没有
@ServiceConnection、jakarta 生态的新版本库逐个要挑旧 artifact。 - 唯一的实质阻力是 Spring Cloud Alibaba/Nacos:Boot 3 需要 SCA 2022.x/2023.x 配套(可用,但要一次性把三个 BOM 同步升级)。附带建议:MVP 只有两个服务、部署拓扑固定,可以评估干脆移除 Nacos,Feign 用静态 URL + 环境变量——同时消掉 B3 里配置导入和测试上下文两个痛点;等模块数量上来再引入注册中心也不迟。此项需与团队确认,不阻塞升级本身。
若团队仍决定先交付 MVP 后升级,则必须接受:任务 4/5/6 的产出物在升级时二次返工,且身份服务在无安全补丁的框架上对外——我不推荐。
5. 建议实现顺序
0. 决策先行:Boot 3 升级(含是否移除 Nacos)、auth_sessions 归属(建议下沉 patbond-user)
1. 工程基线:common 瘦身为纯契约、提交默认 application.yml(env 注入)、test profile 可离线启动 [解 B3]
2. 任务 1 + 2 + 3:Flyway baseline → UUID 持久化 → 统一异常与校验(同 PR 链,先冻结对外契约) [解 B1/B4/B5]
3. 任务 4:JWT + refresh 轮换 + /internal 保护 + 登录失败锁定 [解 B2]
4. 任务 5:Testcontainers 集成测试(随 2/3/4 增量补,不后置)
5. 任务 6:openapi.yaml 评审冻结
6. 任务 7:Flutter 登录纵切(依赖 5 的冻结契约)
7. 任务 8:后端 E2E 进 CI 门禁;Flutter integration_test 本地跑通
测试(任务 5)实际应与 2-4 步同 PR 交付,此处单列仅表示依赖关系。