From 273064c10d19864629e3dd76f545314787f81455 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 4 Sep 2026 12:18:29 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=AC=AC=E4=B8=89=E6=B3=A2=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E4=B8=A4=E7=AB=AF=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=85=A5=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 API 契约:docs/api/openapi.yaml(T6a 正式化)与契约说明页(契约先行原则) - 入档报告 16(后端 JWT 会话,37→73 测试)与 17(Flutter 登录纵切,7→30 测试) - 进展看板更新至第三波完成,第四波为联调 E2E → CI → 编排 → 埋点 - 门禁:mkdocs build --strict 通过 --- docs/api/index.md | 5 + docs/api/openapi.yaml | 411 ++++++++++++++++++ .../iteration-1/16-backend-auth-report.md | 102 +++++ .../iteration-1/17-flutter-login-report.md | 67 +++ .../iterations/iteration-1/index.md | 30 +- mkdocs.yml | 4 + 6 files changed, 606 insertions(+), 13 deletions(-) create mode 100644 docs/api/index.md create mode 100644 docs/api/openapi.yaml create mode 100644 docs/development/iterations/iteration-1/16-backend-auth-report.md create mode 100644 docs/development/iterations/iteration-1/17-flutter-login-report.md diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..1424dcb --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,5 @@ +# API 契约 + +第一迭代认证域的正式契约见 [openapi.yaml](openapi.yaml)(OpenAPI 3):注册、登录、刷新、退出、当前用户 5 个端点,统一错误信封 `{code, message, data}` 与错误码表(40000/40100/40101/40102/40900/40901/42300),以及会话轮换与登录锁定策略说明。 + +约定:契约变更须先改本文件目录下的 OpenAPI,再改实现(契约先行);错误码只增不改义。 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..daa184b --- /dev/null +++ b/docs/api/openapi.yaml @@ -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 token:JWT(RS256),有效期 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 (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 diff --git a/docs/development/iterations/iteration-1/16-backend-auth-report.md b/docs/development/iterations/iteration-1/16-backend-auth-report.md new file mode 100644 index 0000000..eb4200e --- /dev/null +++ b/docs/development/iterations/iteration-1/16-backend-auth-report.md @@ -0,0 +1,102 @@ +# 16 后端认证报告:JWT + refresh 会话 + /internal 鉴权 + OpenAPI(第一迭代·第三波) + +- 执行人:Senior Developer +- 日期:2026-09-04 +- 仓库:`patbond-api`(dev 分支,已提交);`patbond-doc` 仅新增 `docs/api/openapi.yaml` 与本报告(均不提交,由主会话收口) +- 范围:T4(JWT RS256 access token、refresh 会话轮换与 family 撤销、`/api/v1` 前缀迁移、`/internal` 服务间鉴权、登录失败限制、expiresAt 时区修复)+ T6a(OpenAPI 3 正式契约) +- 门禁:`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` → **BUILD SUCCESS,73 测试 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`(上一波已有能力,字段名无冲突,前端可忽略);错误码新增 **42300(HTTP 423,登录锁定)**——工单第 6 项要求把锁定行为写进契约,冻结稿错误码表没有为它留码,属必要新增,见 §5。 + +## 3. Token 与会话实现(ADR-003,全部可配置) + +### Access token(patbond-auth `security/JwtSigner`) +- RS256(jjwt 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-user,DB 落地) +- 简化为**按用户名**计数(而非工单示例的"用户名+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 SUCCESS(common 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) | auth(PEM 路径或内联) | +| `PATBOND_JWT_PUBLIC_KEY` | 无 | user(PEM 路径或内联;未配置时 /api/v1/** 返回 500 并记 ERROR) | +| `PATBOND_ACCESS_TTL` / `PATBOND_REFRESH_TTL` | 15m / 30d | auth / user(ADR-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}` 目前无调用方(上一波遗留),保留未动。 diff --git a/docs/development/iterations/iteration-1/17-flutter-login-report.md b/docs/development/iterations/iteration-1/17-flutter-login-report.md new file mode 100644 index 0000000..34665d0 --- /dev/null +++ b/docs/development/iterations/iteration-1/17-flutter-login-report.md @@ -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 / me;logout 服务端失败也保证本地清除 | +| 页面 | `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);错误三层映射——字段级 errorText(onChanged 即清)、40100 → 横幅「用户名或密码错误」+ `SemanticsService.sendAnnouncement` 播报、HTTP 429 → 横幅「尝试次数过多,请稍后再试」、网络 → SnackBar「网络异常,请检查网络后重试」+ 重试 action;成功后 `finishAutofillContext()`,状态机 300ms fade 进主壳;协议行与预留区一律不渲染(ADR-004)。 + +**注册页**(组装稿 §6):透明返回栏顶部左对齐;四字段(用户名/手机号/密码/确认密码)失焦校验 + 提交总校验,文案照 04 规范 §3.2;密码 helperText 走主题 muted(FIX-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-1(TagPill 对比度)按 12 号报告裁决仍另开工单,本次未动。 + +--- +**Frontend Developer** · 2026-09-04 · 门禁:format 0 changed / analyze 0 issues / test 30 passed diff --git a/docs/development/iterations/iteration-1/index.md b/docs/development/iterations/iteration-1/index.md index 7743d7b..6fe786a 100644 --- a/docs/development/iterations/iteration-1/index.md +++ b/docs/development/iterations/iteration-1/index.md @@ -1,15 +1,15 @@ # 第一迭代进展看板 > 目标:真实登录纵切(注册 → 登录 → 获取当前用户 → 退出),依据[开发实施计划](../../development-plan.md)第 8 节。 -> 更新日期:2026-09-04。本页是团队共享的进度事实来源,每波工作交付后更新。 +> 更新日期:2026-09-04(第三波交付后)。本页是团队共享的进度事实来源,每波工作交付后更新。 ## 当前状态一览 | 状态 | 内容 | | --- | --- | -| ✅ 已完成 | 开工分析(报告 01-06)、工程基线(第一波)、持久化纵切(第二波)、Git 工作流建章 | -| 🔜 下一步 | 第三波:JWT + refresh 会话 → `/internal` 鉴权 → OpenAPI 冻结 → Flutter 登录页拼装 → 端到端用例 | -| ⚠️ 未闭环 | token 仍为随机串(表已就绪)、`/internal` 无鉴权、CI 载体缺失、UI 待修 FIX-1/FIX-2、Flutter README 门禁参数(m2) | +| ✅ 已完成 | 开工分析(01-06)、工程基线(第一波)、持久化纵切(第二波)、JWT 会话 + Flutter 登录纵切 + OpenAPI 契约(第三波)、ADR-001~008 | +| 🔜 下一步 | 第四波:真实前后端联调与端到端验证 → CI 载体 → 本地编排(compose)→ 埋点落地 | +| ⚠️ 未闭环 | access token 无主动吊销(≤15 分钟窗口)、/internal 为静态密钥、auth_sessions 无过期清理任务、前端全链路仅 mock 验证未联调、CI 载体缺失、TagPill 设计债 | ## 已完成(附提交) @@ -21,18 +21,22 @@ **第二波:持久化纵切** -- Flyway V1 baseline(identity/media)、用户 UUIDv7 持久化到 PostgreSQL、统一异常与错误码透传(修复错误码折叠),测试 21 → 37,全部经 Testcontainers 验证(`patbond-api@bd20adc`,报告 10)。 -- 独立复核确认第一波声明属实(报告 11);UI 设计 QA + 登录/注册/Splash 组装稿(报告 12);埋点工程规范含 OpenAPI/DDL 草案(报告 13);mkdocs 门禁打通(报告 14)。 -- Git 工作流规范入档(`patbond-doc@027876a`,见 [Git 工作流规范](../../git-workflow.md)),报告 15。 +- Flyway V1 baseline(identity/media)、用户 UUIDv7 持久化到 PostgreSQL、统一异常与错误码透传,测试 21 → 37,全部经 Testcontainers 验证(`patbond-api@bd20adc`,报告 10)。 +- 独立复核确认第一波声明属实(报告 11);UI 设计 QA + 登录/注册/Splash 组装稿(报告 12);埋点工程规范(报告 13);mkdocs 门禁打通(报告 14);Git 工作流规范入档(`patbond-doc@027876a`,报告 15)。 +- ADR-006/007/008 入档:测试与交付容器化、部署形态、PostgreSQL 18 基线(Testcontainers 镜像切换 `patbond-api@43ab6c5`)。 -## 下一步(第三波,未启动) +**第三波:认证纵切两端交付** -1. **T4 JWT + refresh 会话**:按 ADR-003(access 15 分钟 / refresh 30 天轮换 / 多设备),`identity.auth_sessions` 表已随 V1 就绪;同时保护 `/internal/**`、整改 `expiresAt` 时区。 -2. **T6a OpenAPI 冻结**:错误码契约(报告 10)+ token 字段定型后出契约文档。 -3. **Flutter 登录页拼装**:照报告 12 组装稿实现,顺带修 FIX-1(促销卡渐变对比度)、FIX-2(helperStyle)、m2(README 门禁参数);接入 `--dart-define` 注入 API 地址。 -4. **端到端用例**:注册 → 登录 → 获取当前用户 → 退出,进 CI。 +- 后端 T4 + T6a(`patbond-api@4dc3dcd`,报告 16):JWT RS256(15m/30d 配置项)、refresh 轮换会话(auth_sessions 摘要 + token_family + 重用撤销全族)、多设备并行、登录锁定(42300)、`/api/v1` 前缀、`/internal` 共享密钥鉴权;测试 37 → 73,含双服务真实 HTTP E2E。附带修复两个存量缺陷:Feign 错误解码器被子上下文遮蔽、JDK HttpURLConnection 读不到 401 错误体(此前下游错误在真实链路折叠为 503)。 +- 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 一并修复。 +- OpenAPI 契约正式化:[docs/api/openapi.yaml](../../../api/openapi.yaml),契约先行原则见 [API 契约说明](../../../api/index.md)。 -启动条件已满足(持久化纵切完成 + 复核无否决)。 +## 下一步(第四波,未启动) + +1. **真实联调 + E2E(T8)**:起后端双服务 + compose postgres:18,Flutter 连真实 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)。 ## 环境与构建(新成员必读) diff --git a/mkdocs.yml b/mkdocs.yml index 8ea979b..16bc338 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,5 +23,9 @@ nav: - 13 埋点实现规范: development/iterations/iteration-1/13-tracking-implementation-spec.md - 14 里程碑证据档案: development/iterations/iteration-1/14-evidence-milestone-dossier.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