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

20 KiB
Raw Blame History

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
密码 BCryptspring-security-crypto),这是唯一可直接保留的安全实现 同上
Token UUID.randomUUID() 去掉横线的随机串,服务端不存储、不可验证、不可刷新、不可撤销;响应无 refreshTokenexpiresAtLocalDateTime(无时区,违反第 4.3/6.1 节 ISO 8601 约定) patbond-auth/.../service/AuthService.javadto/AuthTokenResponse.java
服务间调用 auth 经 Feign + Nacos 发现调用 user 的 /internal/users/internal/users/verify-password无任何服务间认证(风险 11.3 patbond-auth/.../client/UserClient.javapatbond-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-webstarter-amqpopenfeignloadbalancernacos-discoverynacos-config、hutool 全量传递依赖——user 模块被动引入 RabbitMQ 和 Feign直接违反第 4.1 节约束 patbond-common/pom.xml
契约 DTO UserProfile.idVerifyPasswordResponse.userIdAuthTokenResponse.userId 均为 LongUserController 路径参数 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.javaCreateUserRequest.java vs SQL ck_users_phone
测试 零测试代码

1.2 patbond-flutter

  • pubspec.yaml 依赖只有 cupertino_iconsshared_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.sql1950 行):7 schema、36 表,事务包裹的一次性 bootstrap;扩展依赖 pgcryptocitextpg_trgmbtree_gist
  • 第一迭代关心的部分:
    • identity.usersuuid PK、username citext UNIQUEstatusversion 乐观锁、软删;phone_e164/email 为部分唯一索引(WHERE status <> 'deleted')——唯一性无法只靠应用层判断,必须靠 DB 约束 + 冲突捕获
    • identity.user_credentials:与 users 1:1,含 failed_login_countfailure_window_started_atlocked_until——登录失败限制(M1 要求)的落库结构已备好。
    • identity.auth_sessionsrefresh 会话表已完整设计——token_family_id(家族撤销/重用检测)、refresh_token_hash bytea 且约束 octet_length=32(即 SHA-256 摘要,不是明文)、access_token_jtirotated_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.regionsFlyway baseline 必须同时含 platform.regions、identity 全部、media.assets,否则建不起来。
    • 种子数据集中在 1272 行之后(含开发用户、开发会话、演示预约),与结构部分天然可分割。

2. 必须先解决的阻塞点(按影响排序)

B1. Long ID vs UUID —— 对外契约级阻塞(风险 11.1)

Long 贯穿 UserProfileVerifyPasswordResponseAuthTokenResponseUserController.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 只有 samplespring.config.import 硬指 Nacospatbond-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.sqlextensionspgcrypto/citext/btree_gist)、platform schema + platform.regionsidentity 全部 5 张表及索引、media.assetsusers.avatar_asset_id FK。pg_trgm/pg_trgm 相关索引不在本迭代范围可不带。
    • db/seed/dev-seed.sqldb/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,mediacreateSchemas=truedefaultSchema 明确指定 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.javaLong idUUID);common 的 UserProfile/VerifyPasswordResponseLongString UUIDLocalDateTimeInstant/OffsetDateTime);patbond-user/pom.xml
  • 新建user/domain/UserEntity + UserCredentialEntity(映射 identity.usersidentity.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-generatorGenerators.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"的折叠逻辑)。
  • 新建
    • commonErrorCode 枚举(稳定业务码,如 USER_NAME_TAKENINVALID_CREDENTIALSTOKEN_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-userpatbond-auth 保留为面向客户端的薄入口(校验参数、编排、签发 JWT)。备选方案是 user 暴露 /internal/sessions CRUD 给 auth 编排,但两跳事务边界更碎,MVP 不值得。
  • Token 方案
    • Access tokenJWT,建议 jjwt 0.12.xnimbus-jose-jwt(比引入整套 spring-security-oauth2 轻)。claimssub=user UUID、jti(回写 auth_sessions.access_token_jti)、iat/exp15-30 分钟)、sid=session id。签名算法:MVP 单签发方可用 HS256(密钥走环境变量),但 M2 起 pet/community 模块都要本地验签,建议直接上 RS256/EdDSA 非对称,公钥随 common 契约或 JWKS 端点分发,避免以后共享密钥扩散。
    • Refresh token:不透明随机串256-bit SecureRandombase64url),服务端只存 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 增加 refreshTokenexpiresIn(秒),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 + Testcontainersorg.testcontainers:postgresql 1.19+)。注意 Boot 2.7 没有 @ServiceConnection3.1+ 特性),用 @DynamicPropertySource 注入容器 URL。
  • 前置B3 必须先解决——test profile 关闭 nacosspring.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.yamlOpenAPI 3.0),只含第一批 auth/me 端点,团队评审后冻结,再实现两端。代码生成文档的备选是 springdoc——注意 Boot 2.7 只能用 springdoc 1.7.x2.x 需要 Boot 3),又一个升级加分项。
  • Flutter 客户端:端点只有 5-6 个,建议手写 dio client + 手写 DTOjson_serializable 可选),不引 openapi-generatordart-dio 生成器的产物风格重、定制成本高,等接口上量再评估)。契约一致性靠任务 5 的契约测试兜底。
  • 新建lib/core/network/api_client.dartdio 实例、baseUrl 环境区分、ApiResponse<T> 信封解包、错误码映射)。

任务 7Flutter 登录页、安全 token 存储、登录态恢复

  • 新增依赖dioflutter_secure_storage(第 4.2 节"access token 仅保存在安全存储";注意 Linux 桌面调试需要 libsecret,团队若在桌面跑 demo 要提前确认)、provider(把手工传递的 ChangeNotifier 挂到树上,为 feature 拆分铺路;不建议本迭代就上 riverpod/bloc 全家桶)。
  • 新建
    • lib/features/auth/login_page.dartregister_page.dartauth_controller.dartAuthState: unknown/unauthenticated/authenticated)。
    • lib/data/repositories/auth_repository.dartlib/data/dto/(auth DTO,字段全部事实类型:UUID String、DateTime)。
    • lib/core/storage/token_storage.dartsecure 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 的纵切集成测试天然覆盖,作为门禁。
  • 客户端 E2Eintegration_test 包写一条 happy path(输入注册→进主页→退出回登录页),本地对着 docker-compose 起的后端跑;进 CI 依赖 M0 的编排产物,第一迭代可先手动执行 + 记录在测试文档。
  • 前置:需要一份最小 docker-compose.ymlPostgreSQL + 两个服务;若采纳下面的建议甚至不需要 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/NacosBoot 3 需要 SCA 2022.x/2023.x 配套(可用,但要一次性把三个 BOM 同步升级)。附带建议:MVP 只有两个服务、部署拓扑固定,可以评估干脆移除 NacosFeign 用静态 URL + 环境变量——同时消掉 B3 里配置导入和测试上下文两个痛点;等模块数量上来再引入注册中心也不迟。此项需与团队确认,不阻塞升级本身。

若团队仍决定先交付 MVP 后升级,则必须接受:任务 4/5/6 的产出物在升级时二次返工,且身份服务在无安全补丁的框架上对外——我不推荐。

5. 建议实现顺序

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. 任务 7Flutter 登录纵切(依赖 5 的冻结契约)
7. 任务 8:后端 E2E 进 CI 门禁;Flutter integration_test 本地跑通

测试(任务 5)实际应与 2-4 步同 PR 交付,此处单列仅表示依赖关系。