diff --git a/.kiro/specs/refresh-token/design.md b/.kiro/specs/refresh-token/design.md new file mode 100644 index 00000000..6dfd3ea3 --- /dev/null +++ b/.kiro/specs/refresh-token/design.md @@ -0,0 +1,315 @@ +# Design Document: Refresh Token 机制 + +## Overview + +为 HoneyBox 前端 API 实现双 Token 认证机制,包括: +- 后端:修改登录响应、新增刷新接口、新增数据库表 +- 前端:实现自动刷新逻辑、请求队列管理 + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Token 流程图 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ 登录请求 ┌──────────────┐ │ +│ │ 前端 │ ──────────────> │ AuthController │ │ +│ │ UniApp │ │ /api/login │ │ +│ └──────────┘ └────────┬────────┘ │ +│ │ │ │ +│ │ 返回 accessToken │ 生成双 Token │ +│ │ + refreshToken ▼ │ +│ │ <────────────────── ┌──────────────────┐ │ +│ │ │ AuthService │ │ +│ │ │ - GenerateTokens│ │ +│ │ │ - SaveRefreshToken │ +│ │ └────────┬─────────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌──────────────────┐ │ +│ │ │ user_refresh_tokens │ │ +│ │ │ (Database) │ │ +│ │ └──────────────────┘ │ +│ │ │ +│ │ API 请求 (携带 accessToken) │ +│ │ ──────────────────────────────────────────> │ +│ │ │ +│ │ 401 Unauthorized (Token 过期) │ +│ │ <────────────────────────────────────────── │ +│ │ │ +│ │ 刷新请求 (携带 refreshToken) │ +│ │ ──────────────────> ┌──────────────────┐ │ +│ │ │ /api/refresh │ │ +│ │ └────────┬─────────┘ │ +│ │ │ │ +│ │ 返回新的双 Token │ Token 轮换 │ +│ │ <────────────────────────────┘ │ +│ │ │ +│ │ 重试原始请求 (新 accessToken) │ +│ │ ──────────────────────────────────────────> │ +│ │ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Components and Interfaces + +### 1. 后端组件 + +#### 1.1 UserRefreshToken 实体 (新增) + +```csharp +// server/HoneyBox/src/HoneyBox.Model/Entities/UserRefreshToken.cs +[Table("user_refresh_tokens")] +public class UserRefreshToken +{ + [Key] + public long Id { get; set; } + + public int UserId { get; set; } + + [Required] + [MaxLength(256)] + public string TokenHash { get; set; } = null!; + + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.Now; + + [MaxLength(50)] + public string? CreatedByIp { get; set; } + + public DateTime? RevokedAt { get; set; } + + [MaxLength(50)] + public string? RevokedByIp { get; set; } + + [MaxLength(256)] + public string? ReplacedByToken { get; set; } + + // 计算属性 + [NotMapped] + public bool IsExpired => DateTime.Now >= ExpiresAt; + + [NotMapped] + public bool IsRevoked => RevokedAt != null; + + [NotMapped] + public bool IsActive => !IsRevoked && !IsExpired; + + // 导航属性 + [ForeignKey("UserId")] + public virtual User User { get; set; } = null!; +} +``` + +#### 1.2 登录响应模型 (修改) + +```csharp +// server/HoneyBox/src/HoneyBox.Model/Models/Auth/LoginResponse.cs +public class LoginResponse +{ + /// + /// Access Token (JWT),有效期 30 分钟 + /// + public string AccessToken { get; set; } = null!; + + /// + /// Refresh Token,有效期 7 天 + /// + public string RefreshToken { get; set; } = null!; + + /// + /// Access Token 过期时间(秒) + /// + public long ExpiresIn { get; set; } +} +``` + +#### 1.3 IAuthService 接口 (扩展) + +```csharp +// 新增方法 +Task RefreshTokenAsync(string refreshToken, string? ipAddress); +Task RevokeTokenAsync(string refreshToken, string? ipAddress); +Task RevokeAllUserTokensAsync(int userId, string? ipAddress); +``` + +#### 1.4 AuthController (新增刷新接口) + +```csharp +// POST /api/refresh +[HttpPost("refresh")] +[AllowAnonymous] +public async Task> RefreshToken([FromBody] RefreshTokenRequest request) +``` + +### 2. 前端组件 + +#### 2.1 RequestManager (修改) + +```javascript +// honey_box/common/request.js +class RequestManager { + // 新增:刷新状态标记 + static isRefreshing = false; + + // 新增:等待刷新的请求队列 + static refreshQueue = []; + + // 新增:刷新 Token 方法 + static async refreshToken() { ... } + + // 修改:request 方法,处理 401 自动刷新 + static async request(param) { ... } +} +``` + +## Data Models + +### 数据库表结构 + +```sql +-- 新增 user_refresh_tokens 表 +CREATE TABLE user_refresh_tokens ( + Id BIGINT IDENTITY(1,1) PRIMARY KEY, + UserId INT NOT NULL, + TokenHash NVARCHAR(256) NOT NULL, + ExpiresAt DATETIME2 NOT NULL, + CreatedAt DATETIME2 NOT NULL DEFAULT GETDATE(), + CreatedByIp NVARCHAR(50) NULL, + RevokedAt DATETIME2 NULL, + RevokedByIp NVARCHAR(50) NULL, + ReplacedByToken NVARCHAR(256) NULL, + + CONSTRAINT FK_UserRefreshTokens_Users + FOREIGN KEY (UserId) REFERENCES users(id) ON DELETE CASCADE, + + INDEX IX_UserRefreshTokens_UserId (UserId), + INDEX IX_UserRefreshTokens_TokenHash (TokenHash) +); +``` + +### 前端存储结构 + +```javascript +// uni.storage 存储 +{ + "accessToken": "eyJhbGciOiJIUzI1NiIs...", // JWT Access Token + "refreshToken": "a1b2c3d4e5f6...", // Refresh Token + "tokenExpireTime": 1706000000000 // Access Token 过期时间戳 +} +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: 登录响应结构完整性 + +*For any* 成功的登录请求(微信或手机号),返回的响应对象 SHALL 包含非空的 accessToken、非空的 refreshToken 和正整数的 expiresIn。 + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Access Token 有效期正确性 + +*For any* 生成的 Access Token,解析其 JWT payload 中的 exp claim,该值与当前时间的差值 SHALL 在 29-31 分钟范围内(允许 1 分钟误差)。 + +**Validates: Requirements 1.3** + +### Property 3: Refresh Token 存储正确性 + +*For any* 成功的登录请求,数据库中 SHALL 存在对应的 UserRefreshToken 记录,且 ExpiresAt 与当前时间的差值在 6.9-7.1 天范围内。 + +**Validates: Requirements 1.4, 1.5** + +### Property 4: Token 刷新有效性 + +*For any* 有效的 Refresh Token,调用刷新接口 SHALL 返回新的有效 accessToken 和 refreshToken,且新旧 Token 不相同。 + +**Validates: Requirements 2.1** + +### Property 5: 无效 Token 拒绝 + +*For any* 随机生成的无效字符串作为 Refresh Token,调用刷新接口 SHALL 返回错误响应。 + +**Validates: Requirements 2.4** + +### Property 6: Token 轮换完整性 + +*For any* 成功的 Token 刷新操作,旧的 Refresh Token SHALL 被标记为已撤销(RevokedAt 非空),且其 ReplacedByToken 字段 SHALL 指向新 Token 的哈希值。 + +**Validates: Requirements 2.5, 2.6** + +### Property 7: Token 哈希存储安全性 + +*For any* 存储在数据库中的 TokenHash,其值 SHALL 不等于原始 Refresh Token 明文,且长度为 64 字符(SHA256 十六进制)。 + +**Validates: Requirements 4.1** + +### Property 8: Token 有效性检查完整性 + +*For any* 已过期或已撤销的 Refresh Token,调用刷新接口 SHALL 返回错误响应,不返回新 Token。 + +**Validates: Requirements 5.3** + +### Property 9: UserAccount 兼容性维护 + +*For any* 成功的登录请求,UserAccount 表中对应用户的 account_token 字段 SHALL 被更新。 + +**Validates: Requirements 6.3** + +## Error Handling + +### 后端错误处理 + +| 错误场景 | HTTP 状态码 | 错误消息 | +|---------|------------|---------| +| Refresh Token 为空 | 400 | 刷新令牌不能为空 | +| Refresh Token 无效 | 401 | 无效的刷新令牌 | +| Refresh Token 已过期 | 401 | 刷新令牌已过期 | +| Refresh Token 已撤销 | 401 | 刷新令牌已失效 | +| 用户不存在 | 401 | 用户不存在 | +| 用户已禁用 | 401 | 账号已被禁用 | + +### 前端错误处理 + +| 错误场景 | 处理方式 | +|---------|---------| +| 刷新成功 | 更新本地 Token,重试原请求 | +| 刷新失败 (401) | 清除本地存储,跳转登录页 | +| 刷新失败 (网络错误) | 显示网络错误提示 | +| 并发刷新 | 加入队列等待 | + +## Testing Strategy + +### 单元测试 + +1. **AuthService 测试** + - 登录返回双 Token + - Token 刷新成功 + - Token 刷新失败(各种错误场景) + - Token 撤销 + +2. **JwtService 测试** + - Access Token 生成和验证 + - Token 过期时间正确 + +### 属性测试 + +使用 FsCheck 或类似的属性测试库: + +1. **登录响应属性测试** - 验证响应结构完整性 +2. **Token 有效期属性测试** - 验证 Token 过期时间 +3. **Token 轮换属性测试** - 验证轮换机制正确性 +4. **Token 哈希属性测试** - 验证存储安全性 + +### 集成测试 + +1. **完整登录流程测试** +2. **Token 刷新流程测试** +3. **并发刷新测试** + +### 测试框架 + +- 后端:xUnit + FsCheck +- 前端:手动测试(UniApp 环境限制) diff --git a/.kiro/specs/refresh-token/requirements.md b/.kiro/specs/refresh-token/requirements.md new file mode 100644 index 00000000..b7319f2a --- /dev/null +++ b/.kiro/specs/refresh-token/requirements.md @@ -0,0 +1,85 @@ +# Requirements Document + +## Introduction + +为 HoneyBox 前端 API 实现双 Token 认证机制(Access Token + Refresh Token),解决 Token 过期后用户需要重新登录的问题,提升用户体验。当 Access Token 过期时,前端可以使用 Refresh Token 自动获取新的 Token,实现无感刷新。 + +## Glossary + +- **Access_Token**: 短期有效的 JWT 令牌,用于 API 请求认证,有效期 30 分钟 +- **Refresh_Token**: 长期有效的刷新令牌,用于获取新的 Access Token,有效期 7 天 +- **Token_Rotation**: Token 轮换机制,每次刷新时生成新的 Refresh Token 并废弃旧的 +- **Auth_Service**: 认证服务,处理登录、Token 生成和刷新逻辑 +- **Request_Manager**: 前端网络请求管理器,封装统一的请求方法 +- **User_Refresh_Token**: 用户刷新令牌数据库实体,存储 Refresh Token 信息 + +## Requirements + +### Requirement 1: 登录接口返回双 Token + +**User Story:** As a 用户, I want 登录成功后获取 Access Token 和 Refresh Token, so that 我可以在 Access Token 过期后使用 Refresh Token 刷新而无需重新登录。 + +#### Acceptance Criteria + +1. WHEN 用户通过微信小程序登录成功, THE Auth_Service SHALL 返回包含 accessToken、refreshToken 和 expiresIn 的响应对象 +2. WHEN 用户通过手机号验证码登录成功, THE Auth_Service SHALL 返回包含 accessToken、refreshToken 和 expiresIn 的响应对象 +3. THE Auth_Service SHALL 生成有效期为 30 分钟的 Access_Token +4. THE Auth_Service SHALL 生成有效期为 7 天的 Refresh_Token +5. THE Auth_Service SHALL 将 Refresh_Token 的哈希值存储到 User_Refresh_Token 表中 + +### Requirement 2: Token 刷新接口 + +**User Story:** As a 用户, I want 使用 Refresh Token 获取新的 Access Token, so that 我可以在 Access Token 过期后继续使用应用而无需重新登录。 + +#### Acceptance Criteria + +1. WHEN 用户携带有效的 Refresh_Token 请求刷新接口, THE Auth_Service SHALL 返回新的 accessToken、refreshToken 和 expiresIn +2. WHEN 用户携带已过期的 Refresh_Token 请求刷新接口, THE Auth_Service SHALL 返回错误信息 "刷新令牌已过期" +3. WHEN 用户携带已撤销的 Refresh_Token 请求刷新接口, THE Auth_Service SHALL 返回错误信息 "刷新令牌已失效" +4. WHEN 用户携带无效的 Refresh_Token 请求刷新接口, THE Auth_Service SHALL 返回错误信息 "无效的刷新令牌" +5. WHEN Token 刷新成功, THE Auth_Service SHALL 撤销旧的 Refresh_Token 并生成新的 Refresh_Token(Token 轮换) +6. THE Auth_Service SHALL 记录 Token 轮换的关联关系用于安全审计 + +### Requirement 3: 前端自动刷新机制 + +**User Story:** As a 用户, I want 前端在 Access Token 过期时自动刷新, so that 我可以无感知地继续使用应用。 + +#### Acceptance Criteria + +1. WHEN 前端收到 HTTP 401 响应, THE Request_Manager SHALL 自动使用 Refresh_Token 调用刷新接口 +2. WHEN Token 刷新成功, THE Request_Manager SHALL 更新本地存储的 accessToken 和 refreshToken +3. WHEN Token 刷新成功, THE Request_Manager SHALL 使用新的 Access_Token 重试原始请求 +4. WHEN Token 刷新失败(Refresh_Token 也过期), THE Request_Manager SHALL 清除本地存储并跳转到登录页 +5. WHILE 正在刷新 Token, THE Request_Manager SHALL 将其他请求加入队列等待刷新完成 +6. WHEN Token 刷新完成, THE Request_Manager SHALL 使用新 Token 依次执行队列中的请求 + +### Requirement 4: Token 存储安全 + +**User Story:** As a 系统管理员, I want Token 安全存储, so that 用户的认证信息不会被泄露。 + +#### Acceptance Criteria + +1. THE Auth_Service SHALL 使用 SHA256 哈希存储 Refresh_Token,不存储明文 +2. THE Request_Manager SHALL 将 accessToken 和 refreshToken 存储在 uni.storage 中 +3. WHEN 用户退出登录, THE Request_Manager SHALL 清除本地存储的所有 Token +4. WHEN 用户退出登录, THE Auth_Service SHALL 撤销该用户的 Refresh_Token + +### Requirement 5: 数据库支持 + +**User Story:** As a 开发者, I want 数据库支持 Refresh Token 存储, so that 系统可以管理和验证 Refresh Token。 + +#### Acceptance Criteria + +1. THE User_Refresh_Token 表 SHALL 包含字段:Id、UserId、TokenHash、ExpiresAt、CreatedAt、CreatedByIp、RevokedAt、RevokedByIp、ReplacedByToken +2. THE User_Refresh_Token 表 SHALL 与 User 表建立外键关联 +3. WHEN 查询 Refresh_Token 时, THE Auth_Service SHALL 同时检查是否过期和是否已撤销 + +### Requirement 6: 向后兼容 + +**User Story:** As a 开发者, I want 新的认证机制向后兼容, so that 现有的前端代码可以平滑迁移。 + +#### Acceptance Criteria + +1. THE Auth_Service SHALL 继续支持旧的登录响应格式(仅返回 token 字符串)直到前端完全迁移 +2. WHEN 前端未传递 refreshToken, THE Auth_Service SHALL 按照旧逻辑处理 401 错误 +3. THE Auth_Service SHALL 继续维护 UserAccount 表中的 account_token 用于兼容旧系统 diff --git a/.kiro/specs/refresh-token/tasks.md b/.kiro/specs/refresh-token/tasks.md new file mode 100644 index 00000000..91119c00 --- /dev/null +++ b/.kiro/specs/refresh-token/tasks.md @@ -0,0 +1,142 @@ +# Implementation Plan: Refresh Token 机制 + +## Overview + +实现双 Token 认证机制,分为后端改动和前端改动两个阶段。后端先完成数据库、实体、服务和接口的改动,前端再实现自动刷新逻辑。 + +## Tasks + +- [x] 1. 后端:数据库和实体层 + - [x] 1.1 创建 UserRefreshToken 实体类 + - 在 `HoneyBox.Model/Entities/` 目录下创建 `UserRefreshToken.cs` + - 包含 Id、UserId、TokenHash、ExpiresAt、CreatedAt、CreatedByIp、RevokedAt、RevokedByIp、ReplacedByToken 字段 + - 添加计算属性 IsExpired、IsRevoked、IsActive + - 添加与 User 表的外键关联 + - _Requirements: 5.1, 5.2_ + + - [x] 1.2 更新 DbContext 添加 UserRefreshTokens DbSet + - 在 `HoneyBoxDbContext.cs` 中添加 `DbSet UserRefreshTokens` + - 配置表名和索引 + - _Requirements: 5.1_ + + - [x] 1.3 创建数据库迁移脚本 + - 创建 `create_user_refresh_tokens.sql` 脚本 + - 包含表创建、索引和外键约束 + - _Requirements: 5.1, 5.2_ + +- [x] 2. 后端:模型和接口层 + - [x] 2.1 创建 LoginResponse 模型 + - 在 `HoneyBox.Model/Models/Auth/` 目录下创建 `LoginResponse.cs` + - 包含 AccessToken、RefreshToken、ExpiresIn 字段 + - _Requirements: 1.1, 1.2_ + + - [x] 2.2 创建 RefreshTokenRequest 和 RefreshTokenResult 模型 + - 创建请求模型包含 RefreshToken 字段 + - 创建结果模型包含 Success、LoginResponse、ErrorMessage 字段 + - _Requirements: 2.1_ + + - [x] 2.3 扩展 IAuthService 接口 + - 添加 `RefreshTokenAsync` 方法 + - 添加 `RevokeTokenAsync` 方法 + - 添加 `RevokeAllUserTokensAsync` 方法 + - _Requirements: 2.1, 4.4_ + +- [-] 3. 后端:服务层实现 + - [x] 3.1 实现 Refresh Token 生成和存储逻辑 + - 在 AuthService 中添加 `GenerateRefreshToken` 私有方法 + - 使用 SHA256 哈希存储 Token + - 设置 7 天有效期 + - _Requirements: 1.4, 1.5, 4.1_ + + - [x] 3.2 修改登录方法返回双 Token + - 修改 `WechatMiniProgramLoginAsync` 返回 LoginResponse + - 修改 `MobileLoginAsync` 返回 LoginResponse + - 调用 GenerateRefreshToken 生成并存储 Refresh Token + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [x] 3.3 实现 RefreshTokenAsync 方法 + - 验证 Refresh Token 有效性(未过期、未撤销) + - 实现 Token 轮换(撤销旧 Token,生成新 Token) + - 记录 ReplacedByToken 关联关系 + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 5.3_ + + - [x] 3.4 实现 RevokeTokenAsync 方法 + - 根据 Token 哈希查找并撤销 + - 记录撤销时间和 IP + - _Requirements: 4.4_ + + - [ ]* 3.5 编写属性测试:登录响应结构完整性 + - **Property 1: 登录响应结构完整性** + - **Validates: Requirements 1.1, 1.2** + + - [ ]* 3.6 编写属性测试:Token 轮换完整性 + - **Property 6: Token 轮换完整性** + - **Validates: Requirements 2.5, 2.6** + +- [x] 4. Checkpoint - 后端服务层完成 + - 确保所有测试通过,ask the user if questions arise. + +- [x] 5. 后端:控制器层 + - [x] 5.1 修改 AuthController 登录接口返回格式 + - 修改 `WechatMiniProgramLogin` 返回 `ApiResponse` + - 修改 `MobileLogin` 返回 `ApiResponse` + - 保持向后兼容(同时返回 token 字段) + - _Requirements: 1.1, 1.2, 6.1_ + + - [x] 5.2 新增 Token 刷新接口 + - 添加 `POST /api/refresh` 端点 + - 接收 RefreshTokenRequest,返回 LoginResponse + - 处理各种错误情况 + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [x] 5.3 新增 Token 撤销接口(可选,用于退出登录) + - 添加 `POST /api/logout` 端点 + - 撤销用户的 Refresh Token + - _Requirements: 4.4_ + +- [x] 6. Checkpoint - 后端完成 + - 确保所有测试通过,ask the user if questions arise. + - 使用 Postman 或 curl 测试接口 + +- [x] 7. 前端:Token 存储改造 + - [x] 7.1 修改登录成功后的 Token 存储逻辑 + - 存储 accessToken 和 refreshToken 到 uni.storage + - 计算并存储 tokenExpireTime + - _Requirements: 4.2_ + + - [x] 7.2 修改退出登录清除 Token 逻辑 + - 清除 accessToken、refreshToken、tokenExpireTime + - 调用后端撤销接口(可选) + - _Requirements: 4.3_ + +- [x] 8. 前端:自动刷新机制 + - [x] 8.1 实现 refreshToken 方法 + - 调用 `/api/refresh` 接口 + - 更新本地存储的 Token + - 返回刷新结果 + - _Requirements: 3.1, 3.2_ + + - [x] 8.2 实现请求队列机制 + - 添加 isRefreshing 状态标记 + - 添加 refreshQueue 请求队列 + - 刷新完成后依次执行队列中的请求 + - _Requirements: 3.5, 3.6_ + + - [x] 8.3 修改 request 方法处理 401 自动刷新 + - 检测 401 响应触发刷新 + - 刷新成功后重试原请求 + - 刷新失败后跳转登录页 + - _Requirements: 3.1, 3.3, 3.4_ + +- [x] 9. Final Checkpoint - 全部完成 + - 确保所有测试通过,ask the user if questions arise. + - 端到端测试:登录 → 等待过期 → 自动刷新 → 继续使用 + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- 后端改动需要先执行数据库迁移脚本 +- 前端改动需要在后端接口完成后进行 +- 建议先在测试环境验证,再部署到生产环境 +- Access Token 有效期设置为 30 分钟,可根据需要调整 +- Refresh Token 有效期设置为 7 天,可根据需要调整 diff --git a/honey_box/common/config.js b/honey_box/common/config.js index 10eba427..83767a95 100644 --- a/honey_box/common/config.js +++ b/honey_box/common/config.js @@ -193,7 +193,9 @@ const defaultConfig = { }], "app_setting": { "key": "app_setting", - "app_name": "友达赏", + "app_name": "哈尼盲盒", + "share_title": "哈尼盲盒上线,来就送!", + "share_title_detail": "哈尼盲盒,正版潮玩手办一番赏", "purchase_popup": "1", "exchange_times": "2", "balance_name": "钻石", diff --git a/honey_box/common/platform/BasePlatform.js b/honey_box/common/platform/BasePlatform.js index bbc29f87..1789787e 100644 --- a/honey_box/common/platform/BasePlatform.js +++ b/honey_box/common/platform/BasePlatform.js @@ -4,6 +4,7 @@ import { import { getPlatform, getAdvert, getConfig, getDanYe } from '../server/config'; +import { logout, clearLoginTokens } from '../server/auth'; import ConfigManager from '../config'; /** * 多端平台抽象基类(父类) @@ -227,16 +228,32 @@ class BasePlatform { /** * 处理退出登录 + * 清除所有Token(accessToken、refreshToken、tokenExpireTime) + * 可选调用后端撤销接口 */ handleLogout() { uni.showModal({ title: '提示', content: '确定要退出登录吗?', - success: (res) => { + success: async (res) => { if (res.confirm) { - uni.removeStorageSync('token'); - uni.removeStorageSync('userinfo'); + try { + // 调用后端撤销接口并清除本地存储 + await logout(); + } catch (error) { + console.log('退出登录时发生错误:', error); + // 即使后端调用失败,也确保清除本地存储 + clearLoginTokens(); + } + // 刷新页面 + // #ifdef H5 window.location.href = window.location.href; + // #endif + // #ifndef H5 + uni.reLaunch({ + url: '/pages/shouye/index' + }); + // #endif } } }); diff --git a/honey_box/common/request.js b/honey_box/common/request.js index a564b4c1..a6c44d35 100644 --- a/honey_box/common/request.js +++ b/honey_box/common/request.js @@ -10,6 +10,12 @@ import { apiWhiteList } from '@/common/config.js' import RouterManager from '@/common/router.js' import { platform } from '@/common/platform/PlatformFactory' class RequestManager { + // Token 刷新状态标记 + static isRefreshing = false; + + // 等待刷新的请求队列 + static refreshQueue = []; + // 缓存对象 static cache = { data: new Map(), @@ -43,6 +49,149 @@ class RequestManager { this.cache.timestamps.set(cacheKey, Date.now()); } + /** + * 刷新 Token + * 使用 Refresh Token 获取新的 Access Token + * @returns {Promise} 刷新是否成功 + */ + static async refreshToken() { + const refreshToken = uni.getStorageSync('refreshToken'); + + if (!refreshToken) { + console.log('没有 refreshToken,无法刷新'); + return false; + } + + try { + const apiBaseUrl = EnvConfig.apiBaseUrl; + const baseUrlWithSlash = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/'; + const requestUrl = baseUrlWithSlash + 'api/refresh'; + + console.log('开始刷新 Token...'); + + const response = await new Promise((resolve, reject) => { + uni.request({ + url: requestUrl, + method: 'POST', + header: { + 'content-type': 'application/json' + }, + data: { + refreshToken: refreshToken + }, + success: (res) => resolve(res), + fail: (err) => reject(err) + }); + }); + + // 检查 HTTP 状态码 + if (response.statusCode === 200 && response.data && response.data.status === 1) { + const data = response.data.data; + + // 更新本地存储的 Token + if (data.accessToken) { + uni.setStorageSync('token', data.accessToken); + uni.setStorageSync('accessToken', data.accessToken); + } + + if (data.refreshToken) { + uni.setStorageSync('refreshToken', data.refreshToken); + } + + if (data.expiresIn) { + // 计算过期时间戳(当前时间 + expiresIn 秒) + const expireTime = Date.now() + (data.expiresIn * 1000); + uni.setStorageSync('tokenExpireTime', expireTime); + } + + console.log('Token 刷新成功'); + return true; + } else { + console.log('Token 刷新失败:', response.data?.msg || '未知错误'); + return false; + } + } catch (error) { + console.error('Token 刷新请求失败:', error); + return false; + } + } + + /** + * 将请求加入等待队列 + * @param {Function} resolve Promise resolve 函数 + * @param {Function} reject Promise reject 函数 + * @param {Object} param 原始请求参数 + */ + static addToRefreshQueue(resolve, reject, param) { + this.refreshQueue.push({ resolve, reject, param }); + } + + /** + * 处理等待队列中的请求 + * 刷新成功后,使用新 Token 重新执行队列中的所有请求 + * @param {boolean} success 刷新是否成功 + */ + static processRefreshQueue(success) { + console.log(`处理刷新队列,共 ${this.refreshQueue.length} 个请求,刷新${success ? '成功' : '失败'}`); + + this.refreshQueue.forEach(({ resolve, reject, param }) => { + if (success) { + // 刷新成功,重新执行请求 + this.request(param) + .then(resolve) + .catch(reject); + } else { + // 刷新失败,拒绝所有等待的请求 + reject({ status: -1, msg: '登录已过期' }); + } + }); + + // 清空队列 + this.refreshQueue = []; + } + + /** + * 清除所有 Token 并跳转登录页 + */ + static clearTokensAndRedirect() { + // 清除所有 Token 相关存储 + uni.removeStorageSync('token'); + uni.removeStorageSync('accessToken'); + uni.removeStorageSync('refreshToken'); + uni.removeStorageSync('tokenExpireTime'); + uni.removeStorageSync('userinfo'); + + // 保存当前页面用于登录后跳转 + var pages = getCurrentPages(); + var currentPage = pages[pages.length - 1]; + if (currentPage) { + var currentRoute = currentPage.route; + var currentParams = currentPage.options || {}; + if (currentRoute && currentRoute !== 'pages/user/login') { + var redirectPath = '/' + currentRoute; + if (Object.keys(currentParams).length > 0) { + var paramString = Object.keys(currentParams) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(currentParams[key])}`) + .join('&'); + redirectPath += '?' + paramString; + } + uni.setStorageSync('redirect', redirectPath); + } + } + + setTimeout(() => { + uni.showToast({ + title: '登录已过期,请重新登录', + icon: 'none' + }); + }, 100); + + RouterManager.navigateTo('/pages/user/login', {}, 'navigateTo') + .catch(err => { + console.error('登录页面跳转失败:', err); + }); + } + /** * 发送带缓存的GET请求 * @param {String} url 请求地址 @@ -247,7 +396,73 @@ class RequestManager { header: header, data: data, success: res => { - console.log("res.data.status", res.data.status) + console.log("res.data.status", res.data.status, "res.statusCode", res.statusCode) + + // 处理 HTTP 401 未授权(Token 过期或无效) + if (res.statusCode === 401) { + console.log('Token过期或无效,尝试自动刷新'); + + // 白名单接口不进行自动刷新,直接拒绝 + if (RequestManager.isUrlInWhitelist(requestUrl)) { + reject({ status: -1, msg: '登录已过期' }); + return; + } + + // 检查是否有 refreshToken + const refreshToken = uni.getStorageSync('refreshToken'); + if (!refreshToken) { + console.log('没有 refreshToken,直接跳转登录页'); + RequestManager.clearTokensAndRedirect(); + reject({ status: -1, msg: '登录已过期' }); + return; + } + + // 如果正在刷新中,将当前请求加入队列等待 + if (RequestManager.isRefreshing) { + console.log('Token 正在刷新中,将请求加入队列'); + RequestManager.addToRefreshQueue(resolve, reject, param); + return; + } + + // 标记开始刷新 + RequestManager.isRefreshing = true; + + // 尝试刷新 Token + RequestManager.refreshToken() + .then(success => { + // 刷新完成,重置标记 + RequestManager.isRefreshing = false; + + if (success) { + console.log('Token 刷新成功,重试原请求'); + // 刷新成功,重试原请求 + RequestManager.request(param) + .then(resolve) + .catch(reject); + + // 处理队列中的其他请求 + RequestManager.processRefreshQueue(true); + } else { + console.log('Token 刷新失败,跳转登录页'); + // 刷新失败,清除 Token 并跳转登录页 + RequestManager.clearTokensAndRedirect(); + reject({ status: -1, msg: '登录已过期' }); + + // 拒绝队列中的所有请求 + RequestManager.processRefreshQueue(false); + } + }) + .catch(error => { + console.error('Token 刷新异常:', error); + RequestManager.isRefreshing = false; + RequestManager.clearTokensAndRedirect(); + reject({ status: -1, msg: '登录已过期' }); + RequestManager.processRefreshQueue(false); + }); + + return; + } + var pages = getCurrentPages() if (res.data.status == 1) { // 请求成功 @@ -292,7 +507,11 @@ class RequestManager { var pages = getCurrentPages() if (res.data.msg != null) { if (res.data.msg.indexOf("没有找到用户信息") > -1) { + // 清除所有Token相关存储 uni.removeStorageSync('token'); + uni.removeStorageSync('accessToken'); + uni.removeStorageSync('refreshToken'); + uni.removeStorageSync('tokenExpireTime'); uni.removeStorageSync('userinfo'); } } @@ -334,7 +553,11 @@ class RequestManager { icon: 'none' }) }, 100) + // 清除所有Token相关存储 uni.removeStorageSync('token'); + uni.removeStorageSync('accessToken'); + uni.removeStorageSync('refreshToken'); + uni.removeStorageSync('tokenExpireTime'); uni.removeStorageSync('userinfo'); // 使用新的路由守卫方法进行跳转 RouterManager.navigateTo('/pages/user/login', {}, 'navigateTo') diff --git a/honey_box/common/server/auth.js b/honey_box/common/server/auth.js index 96aa6b88..4d5f0b5d 100644 --- a/honey_box/common/server/auth.js +++ b/honey_box/common/server/auth.js @@ -3,6 +3,89 @@ */ import RequestManager from '../request'; +/** + * 保存登录Token到本地存储 + * 支持新版双Token格式和旧版单Token格式 + * @param {Object|String} data 登录响应数据 + * @param {String} data.accessToken 访问令牌(新版) + * @param {String} data.refreshToken 刷新令牌(新版) + * @param {Number} data.expiresIn 过期时间(秒)(新版) + */ +export const saveLoginTokens = (data) => { + if (typeof data === 'string') { + // 旧版格式:直接返回token字符串 + uni.setStorageSync('token', data); + } else if (data && typeof data === 'object') { + // 新版格式:返回包含accessToken、refreshToken、expiresIn的对象 + if (data.accessToken) { + uni.setStorageSync('token', data.accessToken); + uni.setStorageSync('accessToken', data.accessToken); + } + if (data.refreshToken) { + uni.setStorageSync('refreshToken', data.refreshToken); + } + if (data.expiresIn) { + // 计算过期时间戳(当前时间 + expiresIn秒) + const tokenExpireTime = Date.now() + (data.expiresIn * 1000); + uni.setStorageSync('tokenExpireTime', tokenExpireTime); + } + // 兼容旧版:如果响应中有token字段(向后兼容) + if (data.token && !data.accessToken) { + uni.setStorageSync('token', data.token); + } + } +}; + +/** + * 清除所有登录Token + */ +export const clearLoginTokens = () => { + uni.removeStorageSync('token'); + uni.removeStorageSync('accessToken'); + uni.removeStorageSync('refreshToken'); + uni.removeStorageSync('tokenExpireTime'); + uni.removeStorageSync('userinfo'); +}; + +/** + * 获取当前存储的Token信息 + * @returns {Object} Token信息对象 + */ +export const getStoredTokens = () => { + return { + token: uni.getStorageSync('token'), + accessToken: uni.getStorageSync('accessToken'), + refreshToken: uni.getStorageSync('refreshToken'), + tokenExpireTime: uni.getStorageSync('tokenExpireTime') + }; +}; + +/** + * 检查Access Token是否即将过期(提前5分钟) + * @returns {Boolean} 是否即将过期 + */ +export const isTokenExpiringSoon = () => { + const tokenExpireTime = uni.getStorageSync('tokenExpireTime'); + if (!tokenExpireTime) { + return false; + } + // 提前5分钟(300秒)认为即将过期 + const bufferTime = 5 * 60 * 1000; + return Date.now() >= (tokenExpireTime - bufferTime); +}; + +/** + * 检查Access Token是否已过期 + * @returns {Boolean} 是否已过期 + */ +export const isTokenExpired = () => { + const tokenExpireTime = uni.getStorageSync('tokenExpireTime'); + if (!tokenExpireTime) { + return false; + } + return Date.now() >= tokenExpireTime; +}; + /** * 微信登录 * @param {Object} params 登录参数 @@ -62,6 +145,36 @@ export const bindMobileByCode = async (mobile, code) => { }); }; +/** + * 刷新Token + * @param {String} refreshToken 刷新令牌 + * @returns {Promise} 刷新结果,包含新的accessToken、refreshToken、expiresIn + */ +export const refreshToken = async (refreshToken) => { + return await RequestManager.post('/refresh', { + refreshToken + }); +}; + +/** + * 退出登录(撤销Refresh Token) + * @returns {Promise} 退出结果 + */ +export const logout = async () => { + const storedRefreshToken = uni.getStorageSync('refreshToken'); + if (storedRefreshToken) { + try { + await RequestManager.post('/logout', { + refreshToken: storedRefreshToken + }); + } catch (error) { + console.log('撤销Token失败,继续清除本地存储:', error); + } + } + // 无论后端调用是否成功,都清除本地存储 + clearLoginTokens(); +}; + /** * 记录登录 * @returns {Promise} 记录结果 diff --git a/honey_box/components/coupon-pop/coupon-pop.vue b/honey_box/components/coupon-pop/coupon-pop.vue index 42803b81..c508a18f 100644 --- a/honey_box/components/coupon-pop/coupon-pop.vue +++ b/honey_box/components/coupon-pop/coupon-pop.vue @@ -30,7 +30,7 @@ -