Compare commits

..

3 Commits

Author SHA1 Message Date
lixi 273064c10d docs: 第三波交付收口——认证契约与两端实现报告入档
- 新增 API 契约:docs/api/openapi.yaml(T6a 正式化)与契约说明页(契约先行原则)
- 入档报告 16(后端 JWT 会话,37→73 测试)与 17(Flutter 登录纵切,7→30 测试)
- 进展看板更新至第三波完成,第四波为联调 E2E → CI → 编排 → 埋点
- 门禁:mkdocs build --strict 通过
2026-09-04 12:18:29 +08:00
lixi 18746ce6fc docs: 增加 ADR-008 将 PostgreSQL 版本基线定为 18
- 零数据窗口期定版:与本机开发库 18.6 对齐,Testcontainers/编排/交付统一 postgres:18
- 切换当日 37 测试于 postgres:18 全绿,Flyway V1 兼容
- 同步开发计划 5.1/M0/CI 门禁与进展看板的版本表述
- 门禁:mkdocs build --strict 通过
2026-09-04 11:33:58 +08:00
lixi b747e09af8 docs: 增加 ADR-007 部署形态决策
- 容器化无状态应用 + MVP 用 compose 数据库挂 volume 加每日备份,规模化后迁云托管数据库
- 连接信息经环境变量注入,保证换库应用层零改动
- 门禁:mkdocs build --strict 通过
2026-09-04 11:30:18 +08:00
8 changed files with 638 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
# API 契约
第一迭代认证域的正式契约见 [openapi.yaml](openapi.yaml)(OpenAPI 3):注册、登录、刷新、退出、当前用户 5 个端点,统一错误信封 `{code, message, data}` 与错误码表(40000/40100/40101/40102/40900/40901/42300),以及会话轮换与登录锁定策略说明。
约定:契约变更须先改本文件目录下的 OpenAPI,再改实现(契约先行);错误码只增不改义。
+411
View File
@@ -0,0 +1,411 @@
openapi: 3.0.3
info:
title: Patbond API — Auth & Me(第一批公开接口)
version: 1.0.0
description: |
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差)。
## 通用约定(development-plan 第 6 节)
- 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
- 所有时间字段为 ISO 8601 且带时区偏移(如 `2026-09-04T04:05:06.789Z`)。
- 统一响应信封 `{"code": 0, "message": "success", "data": …}`;错误同时携带正确的
HTTP 状态码与稳定业务码,业务码永不复用或改号。
- `/internal/**` 为服务间接口,不属于本公开契约,需 `X-Internal-Token` 服务凭证,
未携带或错误一律 401。
## 错误码表
| 业务码 | HTTP | 场景 |
| --- | --- | --- |
| 0 | 200 | 成功 |
| 40000 | 400 | 参数校验失败(含 JSON 不可解析;message 为首个字段错误) |
| 40100 | 401 | 用户名或密码错误 |
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
| 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
| 40400 | 404 | 资源不存在 |
| 40900 | 409 | 用户名已存在(大小写不敏感) |
| 40901 | 409 | 手机号已被使用 |
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
| 50000 | 500 | 服务器内部错误 |
| 50300 | 503 | 依赖服务暂不可用 |
## 会话模型(ADR-003,数值均为服务端配置项)
- access tokenJWTRS256),有效期 15 分钟;由资源服务用公钥本地验签。
- refresh token:不透明随机串,有效期 30 天;**每次刷新即轮换**,旧值立即失效。
- 已轮换/已失效的 refresh token 再次被使用时,判定为重用,**整个 token family
(该登录会话链)全部撤销**,持有者需重新登录。
- 允许多设备并行会话;退出仅撤销当前会话(由所提交的 refreshToken 标识),
其他设备不受影响。已签发的 access token 在剩余有效期内仍可用。
- 登录失败限制:同一账号在 15 分钟窗口内密码错误累计 5 次(配置项),账号锁定
15 分钟;锁定期间即使密码正确也返回 423/42300;一次成功登录重置计数窗口。
servers:
- url: http://127.0.0.1:8081
description: patbond-auth(本地开发,/api/v1/auth/**
- url: http://127.0.0.1:8082
description: patbond-user(本地开发,/api/v1/me
tags:
- name: auth
description: 注册 / 登录 / 刷新 / 退出(patbond-auth
- name: user
description: 当前用户(patbond-user
paths:
/api/v1/auth/register:
post:
tags: [auth]
summary: 注册并创建会话
operationId: register
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'200':
description: 注册成功,返回令牌对
content:
application/json:
schema:
$ref: '#/components/schemas/AuthTokenEnvelope'
'400':
$ref: '#/components/responses/ValidationError'
'409':
description: 用户名或手机号已被占用(code 40900 / 40901
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
usernameTaken:
value: { code: 40900, message: 用户名已存在, data: null }
phoneTaken:
value: { code: 40901, message: 手机号已被使用, data: null }
/api/v1/auth/login:
post:
tags: [auth]
summary: 登录并创建会话
description: 多设备并行:每次登录开启独立会话(独立 token family),互不影响。
operationId: login
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: 登录成功,返回令牌对
content:
application/json:
schema:
$ref: '#/components/schemas/AuthTokenEnvelope'
'400':
$ref: '#/components/responses/ValidationError'
'401':
description: 用户名或密码错误(code 40100)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
invalidCredentials:
value: { code: 40100, message: 用户名或密码错误, data: null }
'423':
description: 登录失败次数过多,账号临时锁定(code 42300)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
locked:
value: { code: 42300, message: 登录失败次数过多,账号已临时锁定, data: null }
/api/v1/auth/refresh:
post:
tags: [auth]
summary: 轮换 refresh token
description: |
成功时返回全新令牌对,旧 refreshToken 立即失效(轮换)。提交已轮换或已失效的
refreshToken 返回 401/40102,且视为重用攻击:该 token family 的全部会话被撤销。
operationId: refresh
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshRequest'
responses:
'200':
description: 轮换成功,返回新的令牌对
content:
application/json:
schema:
$ref: '#/components/schemas/AuthTokenEnvelope'
'400':
$ref: '#/components/responses/ValidationError'
'401':
description: refresh token 已失效或被重用(code 40102
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
invalidated:
value: { code: 40102, message: refresh token 已失效或被重用, data: null }
/api/v1/auth/logout:
post:
tags: [auth]
summary: 退出(撤销当前会话)
description: |
撤销 body 中 refreshToken 对应的会话;其他设备的会话不受影响(ADR-003)。
需携带有效的 access token(从中取用户身份,防止跨账号撤销)。幂等:对已
失效的 refreshToken 仍返回成功。
operationId: logout
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LogoutRequest'
responses:
'200':
description: 已退出
content:
application/json:
schema:
$ref: '#/components/schemas/VoidEnvelope'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/AccessTokenInvalid'
/api/v1/me:
get:
tags: [user]
summary: 当前用户资料
description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
operationId: me
security:
- bearerAuth: []
responses:
'200':
description: 当前用户
content:
application/json:
schema:
$ref: '#/components/schemas/MeEnvelope'
'401':
$ref: '#/components/responses/AccessTokenInvalid'
'404':
description: 用户不存在(如已注销;code 40400)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: 'Authorization: Bearer <accessToken>RS256 JWT'
responses:
ValidationError:
description: 参数校验失败(code 40000
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
validation:
value: { code: 40000, message: 参数校验失败, data: null }
AccessTokenInvalid:
description: access token 缺失、无效或过期(code 40101)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
tokenInvalid:
value: { code: 40101, message: token 无效或过期, data: null }
schemas:
RegisterRequest:
type: object
required: [username, password]
properties:
username:
type: string
minLength: 3
maxLength: 32
description: 用户名,大小写不敏感唯一
example: demo_user
phone:
type: string
pattern: '^\+[1-9][0-9]{7,14}$'
description: 手机号,E.164 格式;可选,唯一
example: '+8613800138000'
password:
type: string
format: password
minLength: 6
maxLength: 64
example: secret123
nickname:
type: string
minLength: 1
maxLength: 32
description: 昵称;可选(冻结稿之外的可选扩展字段,前端可忽略)
example: 小柴
LoginRequest:
type: object
required: [username, password]
properties:
username:
type: string
example: demo_user
password:
type: string
format: password
example: secret123
RefreshRequest:
type: object
required: [refreshToken]
properties:
refreshToken:
type: string
description: 当前持有的 refresh token(不透明随机串)
example: Zx3v…43位base64url…Qk
LogoutRequest:
type: object
required: [refreshToken]
properties:
refreshToken:
type: string
description: 要撤销的当前会话的 refresh token
example: Zx3v…43位base64url…Qk
AuthTokens:
type: object
description: 注册 / 登录 / 刷新共用的令牌对(冻结契约,恰好这 6 个字段)
required:
- userId
- tokenType
- accessToken
- accessTokenExpiresAt
- refreshToken
- refreshTokenExpiresAt
properties:
userId:
type: string
format: uuid
example: 019212aa-0000-7000-8000-000000000001
tokenType:
type: string
enum: [Bearer]
example: Bearer
accessToken:
type: string
description: RS256 JWT,有效期 15 分钟(配置项)
example: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI…
accessTokenExpiresAt:
type: string
format: date-time
description: ISO 8601 带时区
example: '2026-09-04T04:20:06.789Z'
refreshToken:
type: string
description: 不透明随机串,有效期 30 天(配置项),每次刷新轮换
example: Zx3v…43位base64url…Qk
refreshTokenExpiresAt:
type: string
format: date-time
example: '2026-10-04T04:05:06.789Z'
Me:
type: object
description: 当前用户资料(冻结契约,恰好这 4 个字段)
required: [userId, username, createdAt]
properties:
userId:
type: string
format: uuid
example: 019212aa-0000-7000-8000-000000000001
username:
type: string
example: demo_user
phone:
type: string
nullable: true
description: E.164;未绑定时为 null
example: '+8613800138000'
createdAt:
type: string
format: date-time
example: '2026-09-04T04:05:06.789Z'
AuthTokenEnvelope:
type: object
required: [code, message]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
$ref: '#/components/schemas/AuthTokens'
MeEnvelope:
type: object
required: [code, message]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
$ref: '#/components/schemas/Me'
VoidEnvelope:
type: object
required: [code, message]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
nullable: true
example: null
ErrorEnvelope:
type: object
required: [code, message]
properties:
code:
type: integer
description: 稳定业务错误码(见顶部错误码表)
example: 40101
message:
type: string
example: token 无效或过期
data:
nullable: true
example: null
+28 -1
View File
@@ -66,8 +66,35 @@
**决策**2026-09-04): **决策**2026-09-04):
- 自动化测试中的数据库一律通过 Testcontainers 使用 Docker 临时容器(`postgres:16`),不依赖开发者本机数据库;每次测试在干净实例上执行全量 Flyway 迁移。 - 自动化测试中的数据库一律通过 Testcontainers 使用 Docker 临时容器(`postgres:18`,版本基线见 ADR-008),不依赖开发者本机数据库;每次测试在干净实例上执行全量 Flyway 迁移。
- 最终交付将提供 Docker 镜像/编排包(对应开发计划 M6 的容器镜像项)。 - 最终交付将提供 Docker 镜像/编排包(对应开发计划 M6 的容器镜像项)。
- 开发者本人手动测试/联调时可使用自己本机的 PostgreSQL,连接信息经环境变量注入,不入库。 - 开发者本人手动测试/联调时可使用自己本机的 PostgreSQL,连接信息经环境变量注入,不入库。
**理由**:测试可复现、与目标版本(PostgreSQL 16)对齐、不受本机数据库账号/版本差异影响;交付形态与测试基础设施统一到 Docker。 **理由**:测试可复现、与目标版本(PostgreSQL 16)对齐、不受本机数据库账号/版本差异影响;交付形态与测试基础设施统一到 Docker。
## ADR-007 部署形态:容器化应用 + 分阶段数据库策略
**决策**2026-09-04):
- 应用(auth、user 及后续模块)以 Docker 容器交付部署,**容器保持无状态**:文件走对象存储、会话在数据库,任何状态不落容器本地。
- **MVP 阶段**:数据库使用 Docker 容器运行 PostgreSQL(数据挂载 volume 持久化),与应用同机以 docker compose 编排;配套每日 `pg_dump` 备份到独立存储,并演练过恢复流程。
- **规模化阶段**:当用户量与数据重要性上升后,数据库迁移至云托管数据库(RDS 类)或独立数据库服务器,应用容器不动。
- 数据库连接信息始终经环境变量注入(`PATBOND_DB_URL` 等),保证迁移数据库时应用层零改动。
**理由**:双人团队运维预算有限,容器数据库 + volume + 备份在 MVP 阶段完全够用,且与 Testcontainers 测试、Docker 交付包(ADR-006)同一体系;把高可用、故障转移等重运维在需要时外包给云托管,是成本与可靠性的最优路径。守住「应用无状态」这一条纪律,数据库放哪都可随时更换。
**注意**:数据库大版本升级即使在 Docker 中也需要数据迁移(`pg_upgrade` 或 dump/restore),属 ADR 级决策,不随镜像标签随意变更;小版本安全更新随镜像自动跟进。
## ADR-008 PostgreSQL 版本基线定为 18
**决策**(2026-09-04):数据库版本基线从开发计划最初的「PostgreSQL 16+」明确定为 **PostgreSQL 18**。Testcontainers 测试镜像、未来的 compose 编排与交付镜像统一锁 `postgres:18`
**理由**
- 项目尚无生产数据,大版本选择处于零成本窗口;一旦有数据,大版本升级即为一次真实迁移(见 ADR-007 注意事项)。
- PostgreSQL 18 已是发布满一年的稳定版本,与团队本机开发库(18.6)一致,消除本机与基线的版本偏差。
- 不违背开发计划「16+」的原始约束。
**验证**:切换当日 `./mvnw clean test` 全量 37 测试在 postgres:1818.6)容器上通过,Flyway V1 baseline 迁移执行无兼容问题。
**影响**:后续大版本变更须以新 ADR 决策并附全量测试验证;数据库特性使用以 18 为可用上限参考。
+3 -3
View File
@@ -116,7 +116,7 @@ Page/Widget -> Feature Controller -> Repository -> API Client
- JDK 17。不要使用更高版本 JDK 代替团队基线进行发布构建。 - JDK 17。不要使用更高版本 JDK 代替团队基线进行发布构建。
- Maven 3.9+M0 完成后改用仓库内 Maven Wrapper。 - Maven 3.9+M0 完成后改用仓库内 Maven Wrapper。
- PostgreSQL 16+,现有本地库作为开发数据源。 - PostgreSQL 18(版本基线见 ADR-008,现有本地库作为开发数据源。
- Nacos,供当前 `patbond-auth` 发现 `patbond-user` - Nacos,供当前 `patbond-auth` 发现 `patbond-user`
- Flutter/Dart 版本需满足 `patbond-flutter/pubspec.yaml`M0 完成后通过版本管理工具锁定。 - Flutter/Dart 版本需满足 `patbond-flutter/pubspec.yaml`M0 完成后通过版本管理工具锁定。
@@ -199,7 +199,7 @@ NACOS_SERVER_ADDR=127.0.0.1:8848
目标:让所有开发者能用一致方式启动、测试和联调。 目标:让所有开发者能用一致方式启动、测试和联调。
- 确认 Java 17、Flutter SDK、PostgreSQL 16 的固定版本。 - 确认 Java 17、Flutter SDK、PostgreSQL 18 的固定版本。
- 增加 Maven Wrapper 和 Flutter 版本固定方案。 - 增加 Maven Wrapper 和 Flutter 版本固定方案。
- 提供可提交的 `application.yml` 默认配置,敏感值全部由环境变量注入。 - 提供可提交的 `application.yml` 默认配置,敏感值全部由环境变量注入。
- 增加本地基础设施编排:PostgreSQL、NacosRabbitMQ 在异步任务阶段启用。 - 增加本地基础设施编排:PostgreSQL、NacosRabbitMQ 在异步任务阶段启用。
@@ -322,7 +322,7 @@ flutter test
mkdocs build --strict mkdocs build --strict
``` ```
数据库迁移还需在全新 PostgreSQL 16 实例执行一次,并对升级路径执行一次。 数据库迁移还需在全新 PostgreSQL 18 实例执行一次,并对升级路径执行一次。
### 可观测性与产品验证 ### 可观测性与产品验证
@@ -0,0 +1,102 @@
# 16 后端认证报告:JWT + refresh 会话 + /internal 鉴权 + OpenAPI(第一迭代·第三波)
- 执行人:Senior Developer
- 日期:2026-09-04
- 仓库:`patbond-api`dev 分支,已提交);`patbond-doc` 仅新增 `docs/api/openapi.yaml` 与本报告(均不提交,由主会话收口)
- 范围:T4JWT RS256 access token、refresh 会话轮换与 family 撤销、`/api/v1` 前缀迁移、`/internal` 服务间鉴权、登录失败限制、expiresAt 时区修复)+ T6aOpenAPI 3 正式契约)
- 门禁:`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`**BUILD SUCCESS73 测试 0 失败**(上一波 37 → 73),Testcontainers postgres:18,无遗留容器/进程
---
## 1. 采纳的架构(对齐 02 技术评估 §3 任务 4)
**会话逻辑全部下沉 `patbond-user`**identity schema 唯一所有者),**`patbond-auth` 为薄入口**:校验参数、编排内部调用、签发 RS256 JWT。
- `identity.auth_sessions` 的读写只发生在 patbond-user`session/SessionRepository``SessionService``/internal/sessions` 三个内部端点)。
- jti 由 user 在建会话时铸造并落 `access_token_jti`,随响应带回给 auth 嵌入 JWT——一次内部调用完成建会话+对账,无需回写。
- **`/api/v1/me` 由 patbond-user 直接验签**RS256 公钥本地验证,`security/JwtVerifier` + `BearerAuthFilter`),请求不经过 auth。这正是选 RS256 而非 HS256 的理由:M2 起 pet/community 等资源服务同样只拿公钥即可本地验签,共享密钥不扩散。
- auth 侧仅 logout 需要验签(取 sub 作为 userId,防跨账号撤销),用私钥推导出的公钥完成,auth 只需配置一个私钥。
## 2. 公开契约(冻结稿 → 实现,字段零偏差)
- 5 个端点:`POST /api/v1/auth/{register,login,refresh,logout}` + `GET /api/v1/me`,与冻结稿逐字段一致;`AuthTokenResponse` 恰好 6 个字段 `{userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt}``/me` 恰好 `{userId, username, phone, createdAt}`。测试显式断言"多余字段不存在"(旧的 username/nickname/expiresAt 已从响应移除)。
- **旧路径 `/auth/register``/auth/login` 直接删除,不做兼容保留**。理由:尚无任何已发布客户端,Flutter 端正按 `/api/v1` 冻结稿并行开发,保留旧路径只会产生第二套需要测试和废弃的入口。
- 遗留修复:`expiresAt`(无时区 `LocalDateTime`)随响应重构消亡,两个时间字段均为 `OffsetDateTime`,序列化为 ISO 8601 带偏移(报告 10 §5.2 关闭)。
- 契约之外的说明(已在 openapi.yaml 标注):register 仍接受**可选** `nickname`(上一波已有能力,字段名无冲突,前端可忽略);错误码新增 **42300HTTP 423,登录锁定)**——工单第 6 项要求把锁定行为写进契约,冻结稿错误码表没有为它留码,属必要新增,见 §5。
## 3. Token 与会话实现(ADR-003,全部可配置)
### Access tokenpatbond-auth `security/JwtSigner`
- RS256jjwt 0.12.6),claims`sub`=userId、`jti`=auth_sessions.access_token_jti)、`sid`=sessionId、`iss`/`iat`/`exp`;有效期 `patbond.jwt.access-ttl` 默认 **15m**
- 私钥经 `PATBOND_JWT_PRIVATE_KEY` 注入(PEM 文件路径或内联 PEM 皆可),未配置**启动即失败**;公钥同理注入 user(`PATBOND_JWT_PUBLIC_KEY`)。sample 与 README 给出 openssl 生成命令;仓库内无任何密钥材料(测试密钥每次运行时生成,经 `@DynamicPropertySource` 注入)。
### Refresh 会话(patbond-user `session/*`,表结构照 V1 实现)
- 256-bit `SecureRandom` → base64url 不透明串;库中只存 **SHA-256 摘要**(满足 `ck_sessions_refresh_hash` 32 字节约束),测试逐字节比对摘要且断言明文不落库。TTL `patbond.session.refresh-ttl` 默认 **30d**
- **刷新即轮换**:同事务内插入新会话行 + 关闭旧行(`revoked_at`/`rotated_at`/`replaced_by_session_id` 链到新行,reason=`rotated`),新行沿用同一 `token_family_id`。关闭旧行的 UPDATE 带 `revoked_at IS NULL` 守卫,并发轮换同一 token 时只有一个成功,失败方按重用处理。
- **重用检测**:已轮换/已撤销的 refresh token 再次出现 → 撤销该 family 全部存活会话(reason=`reuse_detected`WARN 日志只记 family/user id,不记 token)→ 40102。过期、未知 token 同样 40102。
- **退出**:按(verified userId + refresh 摘要)撤销单个会话(reason=`logout`),幂等;userId 取自验签后的 access token,他人 refresh token 撤销不掉(有专门测试)。多设备并行不互踢(有专门测试)。
### 登录失败限制(patbond-userDB 落地)
- 简化为**按用户名**计数(而非工单示例的"用户名+IP"):计数器在 `identity.user_credentials``failed_login_count`/`failure_window_started_at`/`locked_until`),单条原子 UPDATE 完成窗口重置/累加/触锁判定,多实例与重启安全——这是 IP 维度所不具备的(IP 需额外存储且 MVP 无反向代理拓扑,`X-Forwarded-For` 不可信)。策略:**15 分钟窗口内失败 5 次 → 锁 15 分钟**(三值均为配置项);锁定期间密码正确也返回 **423/42300**;成功登录重置计数并刷 `users.last_login_at`。行为已写入 openapi.yaml 顶部说明。
## 4. /internal 服务间鉴权
- `patbond-user``InternalAuthFilter`OncePerRequestFilter,注册于 `/internal/*`):校验 `X-Internal-Token``patbond.internal-token``PATBOND_INTERNAL_TOKEN` 注入;比较用 `MessageDigest.isEqual` 常数时间);缺失/错误/服务端未配置一律 **401**(信封 code 40101,语义"服务间凭证缺失或无效"——内部接口不在公开错误码表内,复用 401 族最贴切)。未配置时 fail-closed 并记 ERROR。
- `patbond-auth` 侧 Feign `RequestInterceptor` 自动附头;本地开发两端默认值一致(`dev-only-internal-token`,sample 注明生产必须注入强随机值)。
- `/api/v1/*``BearerAuthFilter` 保护(40101),`/internal/*` 由 InternalAuthFilter 保护,无 spring-security 依赖。
## 5. 相对冻结稿的偏差清单
**字段名/端点/信封:零偏差。** 两项显著标注的增补:
1. **★ 新增错误码 42300(HTTP 423)**:登录锁定。工单第 6 项要求锁定行为进契约,冻结稿错误码表无对应码;40100 会误导客户端提示"密码错误"。前端需增加一个分支(可先按通用错误提示处理)。
2. **★ register 的可选 `nickname` 字段保留并写入 OpenAPI**:上一波已实现的能力,删除反而破坏既有内部契约;对只发送冻结稿三字段的客户端完全透明。
另:锁定策略按用户名而非"用户名+IP"(工单示例措辞为"如",视为允许的简化,理由见 §3)。
## 6. 本波挖出并修复的两个存量缺陷(E2E 的直接产出)
跨服务 E2E`AuthE2eIntegrationTest`:同 JVM 启动真实 user 服务 + Testcontainers postgres:18,全程真实 HTTP)首次跑通了 auth→user 的真实失败链路,立刻暴露上一波"错误码不折叠"修复(审计 M1)**在真实调用中从未生效**——当时所有 auth 测试都 mock 了 UserClient
1. **ErrorDecoder 注册位置错误**`ApiErrorDecoder` 原以普通 `@Bean` 放在应用上下文,而 Feign 子上下文自带 `@ConditionalOnMissingBean` 的默认 ErrorDecoder(条件只看子上下文),父上下文的 bean 被遮蔽。修复:移入 `FeignInternalConfig` 并经 `@EnableFeignClients(defaultConfiguration=…)` 注册进每个 Feign 子上下文(该类刻意不加 `@Configuration`,注释已说明原因)。
2. **JDK HttpURLConnection 读不到 401 错误体**:Feign 默认传输层对流式 POST 收到 401 时 `getErrorStream()` 为 null,错误信封不可读,一律折叠 503/50300。修复:auth 引入 `feign-hc5`Apache HttpClient 5,版本随 Spring Cloud BOM),OpenFeign 自动启用。
3. 顺带:ApiErrorDecoder 解析失败不再静默吞异常,改记一条不含响应体的 WARN(响应体可能回显请求数据,不落日志)。
修复后 40100/40102/40900/42300 均端到端原样透传(E2E 断言)。
## 7. 测试与验收执行记录
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`:**73 测试,0 失败 0 错误**BUILD SUCCESScommon 3 / user 40 / auth 30)。新增 36 个,对照工单第 8 项:
| 验收点 | 覆盖测试 |
| --- | --- |
| 注册→登录→me→刷新→旧 refresh 重用被拒且 family 撤销→退出后 refresh 失效 | `AuthE2eIntegrationTest.fullAuthVerticalFlow`(真实 HTTP 全链路)+ `SessionLifecycleIntegrationTest` 7 例(含轮换链 DB 断言、摘要比对、family 撤销后存活会话数=0) |
| access 过期/伪造 → 40101 | E2E `expiredAndForgedAccessTokensAnswer40101` + `MeEndpointTest` 5 例(缺失/过期/伪造/垃圾 token)+ `JwtSignerTest` 5 例(过期/异钥/篡改/fail-fast |
| /internal 无密钥 → 401 | E2E `internalEndpointsRejectCallsWithoutTheServiceCredential` + `InternalAuthFilterTest` 3 例(缺失/错误/sessions 端点) |
| 登录失败限制生效 | E2E `repeatedLoginFailuresLockTheAccount` + `LoginLockoutIntegrationTest` 3 例(锁定、成功重置窗口、锁过期恢复) |
| 多设备并行/退出仅当前会话 | E2E `logoutOnOneDeviceKeepsOtherDevicesLoggedIn` + `SessionLifecycleIntegrationTest`(含"他人 userId 撤销不掉"用例) |
| 冻结契约形状(字段恰好、ISO 8601 带偏移、JWT 格式) | `AuthControllerTest` 16 例(含 40102/42300 透传、logout 三种失败)+ E2E 时间断言 |
既有 37 个测试全部保留并通过(UserControllerTest 仅补服务凭证头)。日志红线复核:全部新增日志语句不含密码、token(含摘要)与手机号全文。`docker ps` 无遗留容器,无遗留后台进程。
## 8. 配置项汇总(新增)
| 环境变量 | 默认 | 服务 |
| --- | --- | --- |
| `PATBOND_INTERNAL_TOKEN` | dev-only-internal-token | 两端(生产必须注入强随机值) |
| `PATBOND_JWT_PRIVATE_KEY` | 无(必填,fail-fast | authPEM 路径或内联) |
| `PATBOND_JWT_PUBLIC_KEY` | 无 | userPEM 路径或内联;未配置时 /api/v1/** 返回 500 并记 ERROR |
| `PATBOND_ACCESS_TTL` / `PATBOND_REFRESH_TTL` | 15m / 30d | auth / userADR-003 |
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` / `_WINDOW` / `_DURATION` | 5 / 15m / 15m | user |
README 已更新(密钥生成步骤、环境变量表、新端点、postgres 16→18 文案对齐 ADR-008)。
## 9. 遗留问题
1. **access token 无主动吊销**:退出/family 撤销只影响 refresh,已签发 access 在剩余 ≤15 分钟内仍有效(行业常规,jti/sid 已入库,将来可加黑名单)。已在 openapi.yaml 说明。
2. **`/internal` 为静态共享密钥**:02 评估建议的最小方案;换 mTLS 或 token exchange 留待后续 ADR。
3. **auth_sessions 无清理任务**:过期/撤销行会累积,需要后续加定期清理(表已有 `ix_auth_sessions_active_expiry` 部分索引支撑)。
4. **user 服务公钥未配置时不 fail-fast**(为测试上下文启动便利,/api/v1 请求时 500+ERROR 日志);若希望与 auth 一致改为启动即失败,是一行改动。
5. **登录锁定不含 IP 维度**(§3 理由);埋点列 `last_failed_at` 已在写。
6. **jjwt 0.12.6 / feign-hc5 版本**jjwt 不在 Boot BOM 内、两模块各自 pin 同一版本;feign-hc5 随 Spring Cloud BOM。
7. `GET /internal/users/by-username/{username}` 目前无调用方(上一波遗留),保留未动。
@@ -0,0 +1,67 @@
# 17 · Flutter 登录纵切实现报告
> 作者:Frontend Developer
> 日期:2026-09-04
> 依据:12-ui-design-qa-and-assembly.md(组装稿)、ADR-003/ADR-004、开发计划 §4.2、接口契约冻结稿
> 提交:`patbond-flutter` dev 分支 `8d890c0`(门禁全绿后提交,未 push)
---
## 1. 交付总览
登录纵切完整落地:网络层(dio)+ 认证会话(安全存储)+ Splash / 登录 / 注册三页 + 主壳真实退出登录,另完成 FIX-1 / FIX-2 / m2 三项顺带修复。门禁三连全绿:`dart format --output=none --set-exit-if-changed lib test`0 changed)、`flutter analyze`No issues)、`flutter test`**30 passed**,其中新增 23 个)。
**契约偏差:零**。所有路径、请求/响应字段名、错误码与冻结稿逐字一致。额外附带两个契约外请求头(服务端可忽略):注册请求带 `Idempotency-Key`(每次提交生成 UUID,token 刷新后的自动重放沿用同一个键),所有请求带 `X-Device-Id`(首启生成、安全存储持久化的设备 UUID)。
## 2. 分层与文件
按开发计划 §4.2 的 Page → Repository → API Client 分层(登录表单状态照组装稿放页面 state,不引入独立 Controller 层):
| 层 | 文件 | 职责 |
| --- | --- | --- |
| 网络 | `lib/core/network/api_client.dart` | dio 封装;base URL 经 `--dart-define=PATBOND_API_BASE_URL` 注入(默认 `http://127.0.0.1:8081`);`validateStatus` 全放行,错误信封统一解析;`AuthInterceptor` 附加 Bearer;鉴权请求遇 HTTP 401 / code 40101 → 单飞刷新后重放一次,重放仍失败清会话抛 `SessionExpiredException` |
| 网络 | `lib/core/network/token_refresher.dart` | 单飞(single-flight)刷新:并发 401 只发一次 `POST /auth/refresh`**仅 40102 / HTTP 401 清会话**,网络失败与 5xx 一律保留 token |
| 网络 | `lib/core/network/api_exception.dart``api_envelope.dart` | 类型化异常(`ApiNetworkException` / `ApiBusinessException` / `ApiRateLimitException` / `SessionExpiredException`+ 错误码常量 + 信封解析 |
| 认证 | `lib/features/auth/session_manager.dart` | token 内存副本 + `flutter_secure_storage` 持久化(`TokenStore` 抽象,测试注入内存实现);认证状态机 unknown/authenticated/unauthenticated**token 不进 SharedPreferences** |
| 认证 | `lib/features/auth/auth_repository.dart` | `AuthRepository` 抽象 + `ApiAuthRepository`login / register / logout / restoreSession / melogout 服务端失败也保证本地清除 |
| 页面 | `lib/features/auth/splash_page.dart``login_page.dart``register_page.dart` | 照组装稿逐项实现(见 §3) |
| 根 | `lib/app/app.dart` | 认证状态机驱动 Splash ↔ 登录 ↔ 主壳,AnimatedSwitcher 300ms fade;测试注入口(sessionManager / authRepository 可注入) |
| 导航 | `lib/core/navigation/fade_route.dart` | `PageRouteBuilder` + `FadeTransition` 300ms(登录 → 注册 push 用) |
## 3. 页面与状态覆盖
**Splash**(组装稿 §7):checking / failed 双态;BrandMark 与登录页同构保证过渡对位;spinner 等待 >300ms 才出现(占位保高度不跳动);最短停留 500ms;refresh 超时 5s;错误态「重试」+「改用账号登录」逃生口(清凭证进登录页);**网络失败不清 refresh token,仅服务端 401/40102 才清**。
**登录页**(组装稿 §5):垂直居中、无 Spacer;两字段仅非空校验(去首尾空格),Focus 包裹失焦校验 + 提交总校验;提交中整表单锁定(字段禁用、注册链接置 null、按钮 loading);错误三层映射——字段级 errorTextonChanged 即清)、40100 → 横幅「用户名或密码错误」+ `SemanticsService.sendAnnouncement` 播报、HTTP 429 → 横幅「尝试次数过多,请稍后再试」、网络 → SnackBar「网络异常,请检查网络后重试」+ 重试 action;成功后 `finishAutofillContext()`,状态机 300ms fade 进主壳;协议行与预留区一律不渲染(ADR-004)。
**注册页**(组装稿 §6):透明返回栏顶部左对齐;四字段(用户名/手机号/密码/确认密码)失焦校验 + 提交总校验,文案照 04 规范 §3.2;密码 helperText 走主题 mutedFIX-2);密码变更时确认密码已有值则重校验一致性;40900 → 用户名字段「该用户名已被使用」、40901 → 手机号字段「该手机号已注册,可直接登录」;注册成功即建立会话直接进首页(popUntil 首路由,不回登录页)。
**主壳/个人中心**`ProfilePage` 的「切换账号或退出登录」接入真实 logout(`POST /auth/logout` Bearer + refreshToken,随后清会话,状态机自动回登录页)。
## 4. 顺带修复
- **FIX-1**:首页促销卡渐变改 `[primaryStrong, primary]`(深端在左承载白字,AA 达标;`brandGradient` 本身未动)。
- **FIX-2**`inputDecorationTheme``helperStyle: TextStyle(color: muted, fontSize: 12)`
- **m2**README 验证命令补 `--output=none`
## 5. 测试(30 通过 = 既有 7 + 新增 23
| 文件 | 数量 | 覆盖 |
| --- | --- | --- |
| `test/core/network/token_refresher_test.dart` | 5 | 并发单飞(仅 1 次请求 + token 轮换)、40102 清会话抛 SessionExpired、网络失败保留 token、单飞复位可重刷、无本地 refresh 直接判失效 |
| `test/features/auth/auth_repository_test.dart` | 10 | 登录成功存会话(含请求体逐字段断言)、40100 业务异常、注册 Idempotency-Key + 40900、40101 刷新后重放一次携带新 token、重放仍 401 清会话、登出网络失败也清本地、会话恢复两分支、5xx → 系统错误、429 → 限流异常(全部 mock dio 假 adapter |
| `test/features/auth/login_page_test.dart` | 4 | 初始 / loading(字段禁用+链接置灰)/ 字段错误(输入即清)/ 横幅错误(40100 文案 + 输入即清)四态 |
| `test/features/auth/register_page_test.dart` | 4 | 渲染(含预留区不渲染断言)、空表单拦截、四字段格式文案逐项、合法提交调接口 |
既有 `widget_test.dart` 改为注入已认证会话 + 假仓库后 pump `App`,断言不变仍通过。任务描述中的「现有 13 个测试」与实际不符——工单开工时仓库为 **7 个**测试(上次提交信息「6 个 widget 测试」+ 1 个导航冒烟),7 个全部保持通过。
## 6. 遗留问题与备忘
1. **`SemanticsService.announce` 已废弃**Flutter 3.44 标记 deprecated,横幅播报改用替代 API `sendAnnouncement(View.of(context), ...)`,行为等价,组装稿 §4 后续修订时可同步文案。
2. **会话过期的 Splash 最短停留**refresh 被服务端判 40102 时清会话即切登录页,该罕见分支可能早于 500ms 最短停留(正常成功/失败/无 token 三路均严格遵守);fade 过渡下无闪烁,判定可接受。
3. **access token 过期时间未做本地预判**:当前依赖 401/40101 被动刷新(契约行为完备);`accessTokenExpiresAt` 已持久化,后续可加过期前主动刷新优化首个请求延迟。
4. **未与真实后端联调**:后端按同一冻结契约并行实现中,本报告所有验证基于 mock dio;联调烟囱测试建议列入下一波工单。
5. DEBT-1TagPill 对比度)按 12 号报告裁决仍另开工单,本次未动。
---
**Frontend Developer** · 2026-09-04 · 门禁:format 0 changed / analyze 0 issues / test 30 passed
@@ -1,15 +1,15 @@
# 第一迭代进展看板 # 第一迭代进展看板
> 目标:真实登录纵切(注册 → 登录 → 获取当前用户 → 退出),依据[开发实施计划](../../development-plan.md)第 8 节。 > 目标:真实登录纵切(注册 → 登录 → 获取当前用户 → 退出),依据[开发实施计划](../../development-plan.md)第 8 节。
> 更新日期:2026-09-04。本页是团队共享的进度事实来源,每波工作交付后更新。 > 更新日期:2026-09-04(第三波交付后)。本页是团队共享的进度事实来源,每波工作交付后更新。
## 当前状态一览 ## 当前状态一览
| 状态 | 内容 | | 状态 | 内容 |
| --- | --- | | --- | --- |
| ✅ 已完成 | 开工分析(报告 01-06)、工程基线(第一波)、持久化纵切(第二波)、Git 工作流建章 | | ✅ 已完成 | 开工分析(01-06)、工程基线(第一波)、持久化纵切(第二波)、JWT 会话 + Flutter 登录纵切 + OpenAPI 契约(第三波)、ADR-001~008 |
| 🔜 下一步 | 第波:JWT + refresh 会话 → `/internal` 鉴权 → OpenAPI 冻结 → Flutter 登录页拼装 → 端到端用例 | | 🔜 下一步 | 第波:真实前后端联调与端到端验证 → CI 载体 → 本地编排(compose)→ 埋点落地 |
| ⚠️ 未闭环 | token 仍为随机串(表已就绪)、`/internal` 无鉴权、CI 载体缺失、UI 待修 FIX-1/FIX-2、Flutter README 门禁参数(m2 | | ⚠️ 未闭环 | access token 无主动吊销(≤15 分钟窗口)、/internal 为静态密钥、auth_sessions 无过期清理任务、前端全链路仅 mock 验证未联调、CI 载体缺失、TagPill 设计债 |
## 已完成(附提交) ## 已完成(附提交)
@@ -21,22 +21,26 @@
**第二波:持久化纵切** **第二波:持久化纵切**
- Flyway V1 baselineidentity/media)、用户 UUIDv7 持久化到 PostgreSQL、统一异常与错误码透传(修复错误码折叠),测试 21 → 37,全部经 Testcontainers 验证(`patbond-api@bd20adc`,报告 10)。 - Flyway V1 baselineidentity/media)、用户 UUIDv7 持久化到 PostgreSQL、统一异常与错误码透传,测试 21 → 37,全部经 Testcontainers 验证(`patbond-api@bd20adc`,报告 10)。
- 独立复核确认第一波声明属实(报告 11);UI 设计 QA + 登录/注册/Splash 组装稿(报告 12);埋点工程规范含 OpenAPI/DDL 草案(报告 13);mkdocs 门禁打通(报告 14)。 - 独立复核确认第一波声明属实(报告 11);UI 设计 QA + 登录/注册/Splash 组装稿(报告 12);埋点工程规范(报告 13);mkdocs 门禁打通(报告 14)Git 工作流规范入档(`patbond-doc@027876a`,报告 15
- Git 工作流规范入档(`patbond-doc@027876a`,见 [Git 工作流规范](../../git-workflow.md)),报告 15 - ADR-006/007/008 入档:测试与交付容器化、部署形态、PostgreSQL 18 基线(Testcontainers 镜像切换 `patbond-api@43ab6c5`
## 下一步(第三波,未启动) **第三波:认证纵切两端交付**
1. **T4 JWT + refresh 会话**:按 ADR-003access 15 分钟 / refresh 30 天轮换 / 多设备),`identity.auth_sessions` 表已随 V1 就绪;同时保护 `/internal/**`、整改 `expiresAt` 时区 - 后端 T4 + T6a`patbond-api@4dc3dcd`,报告 16):JWT RS25615m/30d 配置项)、refresh 轮换会话(auth_sessions 摘要 + token_family + 重用撤销全族)、多设备并行、登录锁定(42300)、`/api/v1` 前缀、`/internal` 共享密钥鉴权;测试 37 → 73,含双服务真实 HTTP E2E。附带修复两个存量缺陷:Feign 错误解码器被子上下文遮蔽、JDK HttpURLConnection 读不到 401 错误体(此前下游错误在真实链路折叠为 503)
2. **T6a OpenAPI 冻结**:错误码契约(报告 10)+ token 字段定型后出契约文档 - Flutter 登录纵切(`patbond-flutter@8d890c0` + 42300 映射 `da25804`,报告 17):dio 网络层(`--dart-define=PATBOND_API_BASE_URL`)、单飞 TokenRefresher、secure storage 会话、Splash/登录/注册三页照组装稿实现、真实退出入口;测试 7 → 30;契约零偏差;FIX-1/FIX-2/m2 一并修复
3. **Flutter 登录页拼装**:照报告 12 组装稿实现,顺带修 FIX-1(促销卡渐变对比度)、FIX-2helperStyle)、m2README 门禁参数);接入 `--dart-define` 注入 API 地址 - OpenAPI 契约正式化:[docs/api/openapi.yaml](../../../api/openapi.yaml),契约先行原则见 [API 契约说明](../../../api/index.md)
4. **端到端用例**:注册 → 登录 → 获取当前用户 → 退出,进 CI。
启动条件已满足(持久化纵切完成 + 复核无否决)。 ## 下一步(第四波,未启动)
1. **真实联调 + E2ET8**:起后端双服务 + compose postgres:18Flutter 连真实 API 走通注册 → 登录 → me → 刷新 → 退出;按报告 14 的验收证据清单收集证据。前置:本地编排(T0-4,compose 拉起 postgres:18 与两个服务)。
2. **CI 载体**:三仓门禁进 CI(命令表在 [Git 工作流规范](../../git-workflow.md))。
3. **埋点落地**:按报告 13 实现 `/api/v1/events` + `platform.product_events` 迁移 + Flutter `lib/analytics/`
4. **杂项**auth_sessions 过期清理任务、Flutter 版本锁定(T0-2)、TagPill 设计债(DEBT-1)。
## 环境与构建(新成员必读) ## 环境与构建(新成员必读)
- 后端构建:`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`(本机默认 JDK 版本过高,必须显式指 17;需 Docker 供 Testcontainers 起 postgres:16)。 - 后端构建:`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`(本机默认 JDK 版本过高,必须显式指 17;需 Docker 供 Testcontainers 起 postgres:18,见 ADR-008)。
- Flutter 门禁:`dart format --output=none --set-exit-if-changed lib test` / `flutter analyze` / `flutter test` - Flutter 门禁:`dart format --output=none --set-exit-if-changed lib test` / `flutter analyze` / `flutter test`
- 文档门禁:`mkdocs build --strict` - 文档门禁:`mkdocs build --strict`
- 测试数据库策略见 ADR-006:自动化测试一律 Testcontainers,个人手动联调用本机 PostgreSQL(环境变量注入连接信息)。 - 测试数据库策略见 ADR-006:自动化测试一律 Testcontainers,个人手动联调用本机 PostgreSQL(环境变量注入连接信息)。
+4
View File
@@ -23,5 +23,9 @@ nav:
- 13 埋点实现规范: development/iterations/iteration-1/13-tracking-implementation-spec.md - 13 埋点实现规范: development/iterations/iteration-1/13-tracking-implementation-spec.md
- 14 里程碑证据档案: development/iterations/iteration-1/14-evidence-milestone-dossier.md - 14 里程碑证据档案: development/iterations/iteration-1/14-evidence-milestone-dossier.md
- 15 Git 收尾报告: development/iterations/iteration-1/15-git-workflow-report.md - 15 Git 收尾报告: development/iterations/iteration-1/15-git-workflow-report.md
- 16 后端认证会话报告: development/iterations/iteration-1/16-backend-auth-report.md
- 17 Flutter 登录纵切报告: development/iterations/iteration-1/17-flutter-login-report.md
- API:
- 契约说明: api/index.md
- 架构: - 架构:
- 技术决策记录: architecture/decisions.md - 技术决策记录: architecture/decisions.md