213
This commit is contained in:
parent
bc898cdc98
commit
434fe8f833
315
.kiro/specs/refresh-token/design.md
Normal file
315
.kiro/specs/refresh-token/design.md
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Access Token (JWT),有效期 30 分钟
|
||||
/// </summary>
|
||||
public string AccessToken { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh Token,有效期 7 天
|
||||
/// </summary>
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Access Token 过期时间(秒)
|
||||
/// </summary>
|
||||
public long ExpiresIn { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 IAuthService 接口 (扩展)
|
||||
|
||||
```csharp
|
||||
// 新增方法
|
||||
Task<RefreshTokenResult> 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<ApiResponse<LoginResponse>> 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 环境限制)
|
||||
85
.kiro/specs/refresh-token/requirements.md
Normal file
85
.kiro/specs/refresh-token/requirements.md
Normal file
|
|
@ -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 用于兼容旧系统
|
||||
142
.kiro/specs/refresh-token/tasks.md
Normal file
142
.kiro/specs/refresh-token/tasks.md
Normal file
|
|
@ -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<UserRefreshToken> 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<LoginResponse>`
|
||||
- 修改 `MobileLogin` 返回 `ApiResponse<LoginResponse>`
|
||||
- 保持向后兼容(同时返回 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 天,可根据需要调整
|
||||
|
|
@ -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": "钻石",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<boolean>} 刷新是否成功
|
||||
*/
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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} 记录结果
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
</view>
|
||||
<view
|
||||
style=" display: flex; align-items: center; justify-content: center; position: relative; margin-top: 50rpx;">
|
||||
<image @click="fenxiang()" :src="$img1('image/pop/btn.png')"
|
||||
<image @click="fenxiang()" :src="$img1('image/pop/')"
|
||||
style="width: 528.47rpx; height:101rpx; position: absolute;">
|
||||
</image>
|
||||
<button v-if="!isH5"
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@
|
|||
</view>
|
||||
<!-- 只显示按钮1的时候,显示这个按钮,上面小的按钮隐藏 -->
|
||||
<view v-if="checkVisible(1) && onlyButton1Visible" class="btn common_bg column center"
|
||||
style="width: 150rpx;height:80rpx;" :style="{ backgroundImage: `url(${$img1('common/btn.png')})` }"
|
||||
style="width: 150rpx;height:80rpx;" :style="{ backgroundImage: `url(${$img1('common/btn1131.png')})` }"
|
||||
@click="handleButtonClick(1)">
|
||||
<text style="font-size: 24rpx;color: #424240;">冲一发</text>
|
||||
</view>
|
||||
|
||||
<!-- 按钮3 -->
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= htmlWebpackPlugin.options.title %>-友达赏</title>
|
||||
<title><%= htmlWebpackPlugin.options.title %>-哈尼盲盒</title>
|
||||
<link rel="icon" type="image/png" href="https://image.zfunbox.cn/icon.png">
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name" : "友达赏王者",
|
||||
"name" : "哈尼盲盒",
|
||||
"appid" : "__UNI__C225F9A",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.2",
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ export default {
|
|||
subTabCur: 0,
|
||||
payType: {
|
||||
1: {
|
||||
title: "友达币",
|
||||
title: this.$config.getAppSetting('currency1_name') || "UU币",
|
||||
icon: "/static/img/pay_type1.png",
|
||||
},
|
||||
2: {
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@
|
|||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "友达赏",
|
||||
"navigationBarTitleText": "哈尼盲盒",
|
||||
"navigationBarBackgroundColor": "#222222",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default {
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达上线,来就送!",
|
||||
title: this.$config.getAppSetting('share_title') || "哈尼盲盒上线,来就送!",
|
||||
path: '/pages/shouye/index?pid=' + uni.getStorageSync('userinfo').ID
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export default {
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达上线,来就送!",
|
||||
title: this.$config.getAppSetting('share_title') || "哈尼盲盒上线,来就送!",
|
||||
path: '/pages/infinite/index'
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export default {
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达上线,来就送!",
|
||||
title: this.$config.getAppSetting('share_title') || "哈尼盲盒上线,来就送!",
|
||||
path: '/pages/infinite/index'
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export default {
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达上线,来就送!",
|
||||
title: this.$config.getAppSetting('share_title') || "哈尼盲盒上线,来就送!",
|
||||
path: '/pages/shouye/index?pid=' + uni.getStorageSync('userinfo').ID
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export default {
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达上线,来就送!",
|
||||
title: this.$config.getAppSetting('share_title') || "哈尼盲盒上线,来就送!",
|
||||
path: '/pages/shouye/index?pid=' + uni.getStorageSync('userinfo').ID
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<image class="app-icon" src="https://image.zfunbox.cn/icon_108.png" mode="aspectFit"
|
||||
@click="handleIconClick"></image>
|
||||
<view class="app-info">
|
||||
<view class="app-name">友达赏</view>
|
||||
<view class="app-name">{{ $config.getAppSetting('app_name') }}</view>
|
||||
<view class="app-version">Version:{{ version }}</view>
|
||||
|
||||
</view>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<page-container title="友达赏" :showBack="false">
|
||||
<page-container :title="$config.getAppSetting('app_name')" :showBack="false">
|
||||
<img src="https://image.zfunbox.cn/icon/zfb-bj.png" style="width: 100vw;min-height: 100vh;" />
|
||||
</page-container>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<page-container title="友达赏-支付成功" :showBack="false">
|
||||
<page-container :title="$config.getAppSetting('app_name') + '-支付成功'" :showBack="false">
|
||||
<view style="height: 20vh;"></view>
|
||||
<view class="pay-success-content">
|
||||
系统处理中,您可以返回'友达赏'小程序,在'消费记录'中查看订单。
|
||||
系统处理中,您可以返回'{{ $config.getAppSetting('app_name') }}'小程序,在'消费记录'中查看订单。
|
||||
</view>
|
||||
<view style="height: 30vw;"></view>
|
||||
<view class="pay-success-button-container" :class="{ 'single-button': isIOS }">
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@
|
|||
subTabCur: 0,
|
||||
payType: {
|
||||
1: {
|
||||
title: "友达币",
|
||||
title: this.$config.getAppSetting('currency1_name') || "UU币",
|
||||
icon: "/static/img/pay_type1.png",
|
||||
},
|
||||
2: {
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达赏,正版潮玩手办一番赏",
|
||||
title: this.$config.getAppSetting('share_title_detail') || "哈尼盲盒,正版潮玩手办一番赏",
|
||||
path: '/pages/user/index?pid=' + uni.getStorageSync('userinfo').ID
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { wxLogin, mobileLogin, sendSms } from '@/common/server/auth.js';
|
||||
import { wxLogin, mobileLogin, sendSms, saveLoginTokens } from '@/common/server/auth.js';
|
||||
import { getUser } from '@/common/server/user.js';
|
||||
|
||||
export default {
|
||||
|
|
@ -262,7 +262,8 @@
|
|||
const res = await mobileLogin(this.mobile, this.verifyCode, uni.getStorageSync('pid'));
|
||||
|
||||
if (res.status == 1) {
|
||||
uni.setStorageSync('token', res.data);
|
||||
// 使用新的Token存储方法,支持双Token格式
|
||||
saveLoginTokens(res.data);
|
||||
this.$c.msg("登录成功");
|
||||
|
||||
// 检查重定向URL
|
||||
|
|
@ -374,7 +375,8 @@
|
|||
console.log(res, '登录成功');
|
||||
|
||||
if (res.status == 1) {
|
||||
uni.setStorageSync('token', res.data);
|
||||
// 使用新的Token存储方法,支持双Token格式
|
||||
saveLoginTokens(res.data);
|
||||
this.$c.msg("登录成功");
|
||||
|
||||
// 检查重定向URL
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<view class="align-center">
|
||||
<image :src="$img1('my/ouqi.png')"></image>
|
||||
<view class="ml20 column">
|
||||
<text>友达币数量</text>
|
||||
<text>{{ $config.getAppSetting('currency1_name') }}数量</text>
|
||||
<text>{{ pageData.user_integral }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -83,13 +83,13 @@
|
|||
<view class="rule">
|
||||
高级赏包、低级赏包最多{{ pageData && pageData.ke_hc_count }}个合并1个,合成将损耗{{
|
||||
pageData && pageData.sun_hao
|
||||
}}友达币!
|
||||
}}{{ $config.getAppSetting('currency1_name') }}!
|
||||
</view>
|
||||
|
||||
<view class="coin-num">
|
||||
总友达币:{{ (mixData && mixData.sum_num) || 0 }} 将合成:{{
|
||||
总{{ $config.getAppSetting('currency1_name') }}:{{ (mixData && mixData.sum_num) || 0 }} 将合成:{{
|
||||
(mixData && mixData.coupon.title) || '普通赏券'
|
||||
}}(友达币{{ (mixData && mixData.sh_num) || 0 }})
|
||||
}}({{ $config.getAppSetting('currency1_name') }}{{ (mixData && mixData.sh_num) || 0 }})
|
||||
</view>
|
||||
|
||||
<view class="btn-lsit">
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export default {
|
|||
let imageUrl = this.$config.getShareImageUrl();
|
||||
return {
|
||||
imageUrl: imageUrl,
|
||||
title: "友达上线,来就送!",
|
||||
title: this.$config.getAppSetting('share_title') || "哈尼盲盒上线,来就送!",
|
||||
path: '/pages/shouye/index?pid=' + uni.getStorageSync('userinfo').ID
|
||||
}
|
||||
},
|
||||
|
|
@ -217,7 +217,7 @@ export default {
|
|||
fenxiang() {
|
||||
var image = this.$baseUrl + "/storage/topic/20240617/30a73c0d5061f700a66f653deeb60f6d.jpg";
|
||||
var path = '/pages/shouye/index?pid=' + uni.getStorageSync('userinfo').ID;
|
||||
this.$c.$fenxiang('友达赏,正版潮玩手办一番赏', '', path, image);
|
||||
this.$c.$fenxiang(this.$config.getAppSetting('share_title_detail') || '哈尼盲盒,正版潮玩手办一番赏', '', path, image);
|
||||
|
||||
},
|
||||
async loadData(pageNo) {
|
||||
|
|
|
|||
192
server/HoneyBox/scripts/create_user_refresh_tokens.sql
Normal file
192
server/HoneyBox/scripts/create_user_refresh_tokens.sql
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
-- 创建用户刷新令牌表
|
||||
-- 用于实现双 Token 认证机制(Access Token + Refresh Token)
|
||||
|
||||
-- 创建用户刷新令牌表
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'user_refresh_tokens')
|
||||
BEGIN
|
||||
CREATE TABLE user_refresh_tokens (
|
||||
id BIGINT IDENTITY(1,1) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
token_hash NVARCHAR(256) NOT NULL,
|
||||
expires_at DATETIME2 NOT NULL,
|
||||
created_at DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||
created_by_ip NVARCHAR(50) NULL,
|
||||
revoked_at DATETIME2 NULL,
|
||||
revoked_by_ip NVARCHAR(50) NULL,
|
||||
replaced_by_token NVARCHAR(256) NULL,
|
||||
CONSTRAINT pk_user_refresh_tokens PRIMARY KEY (id),
|
||||
CONSTRAINT fk_user_refresh_tokens_users FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX ix_user_refresh_tokens_user_id ON user_refresh_tokens(user_id);
|
||||
CREATE INDEX ix_user_refresh_tokens_token_hash ON user_refresh_tokens(token_hash);
|
||||
CREATE INDEX ix_user_refresh_tokens_expires_at ON user_refresh_tokens(expires_at);
|
||||
|
||||
PRINT N'创建用户刷新令牌表 user_refresh_tokens 成功';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT N'用户刷新令牌表 user_refresh_tokens 已存在';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 添加表注释
|
||||
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'user_refresh_tokens')
|
||||
BEGIN
|
||||
-- 检查是否已存在表注释
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = 0
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'用户刷新令牌表,存储 Refresh Token 信息用于双 Token 认证机制',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens';
|
||||
END
|
||||
|
||||
-- 添加列注释
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'id')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'主键ID',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'id';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'user_id')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'用户ID',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'user_id';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'token_hash')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'Token 哈希值(SHA256)',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'token_hash';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'expires_at')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'过期时间',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'expires_at';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'created_at')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'创建时间',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'created_at';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'created_by_ip')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'创建时的 IP 地址',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'created_by_ip';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'revoked_at')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'撤销时间',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'revoked_at';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'revoked_by_ip')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'撤销时的 IP 地址',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'revoked_by_ip';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('user_refresh_tokens')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('user_refresh_tokens') AND name = 'replaced_by_token')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'被替换的新 Token 哈希值(用于 Token 轮换追踪)',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'user_refresh_tokens',
|
||||
@level2type = N'COLUMN', @level2name = N'replaced_by_token';
|
||||
END
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT N'用户刷新令牌表迁移脚本执行完成';
|
||||
GO
|
||||
|
|
@ -8,10 +8,10 @@ using Microsoft.AspNetCore.Mvc;
|
|||
namespace HoneyBox.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 认证控制器 - 处理用户登录、注册和手机号绑定
|
||||
/// 认证控制器 - 处理用户登录、注册、Token刷新和手机号绑定
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 提供微信小程序登录、手机号验证码登录、手机号绑定等功能
|
||||
/// 提供微信小程序登录、手机号验证码登录、Token刷新、手机号绑定等功能
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
|
|
@ -34,18 +34,18 @@ public class AuthController : ControllerBase
|
|||
/// <remarks>
|
||||
/// POST /api/login
|
||||
///
|
||||
/// 使用微信小程序授权code进行登录,返回JWT Token
|
||||
/// Requirements: 1.1-1.8
|
||||
/// 使用微信小程序授权code进行登录,返回双Token(Access Token + Refresh Token)
|
||||
/// Requirements: 1.1, 1.2, 6.1
|
||||
/// </remarks>
|
||||
/// <param name="request">微信登录请求参数,包含授权code</param>
|
||||
/// <returns>登录成功返回JWT Token,失败返回错误信息</returns>
|
||||
/// <returns>登录成功返回LoginResponse(包含accessToken、refreshToken、expiresIn),失败返回错误信息</returns>
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<string>> WechatMiniProgramLogin([FromBody] WechatLoginRequest request)
|
||||
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<LoginResponse>> WechatMiniProgramLogin([FromBody] WechatLoginRequest request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
return ApiResponse<string>.Fail("授权code不能为空");
|
||||
return ApiResponse<LoginResponse>.Fail("授权code不能为空");
|
||||
}
|
||||
|
||||
var result = await _authService.WechatMiniProgramLoginAsync(
|
||||
|
|
@ -53,14 +53,14 @@ public class AuthController : ControllerBase
|
|||
request.Pid,
|
||||
request.ClickId);
|
||||
|
||||
if (result.Success)
|
||||
if (result.Success && result.LoginResponse != null)
|
||||
{
|
||||
_logger.LogInformation("WeChat login successful: UserId={UserId}", result.UserId);
|
||||
return ApiResponse<string>.Success(result.Token!, "登录成功");
|
||||
return ApiResponse<LoginResponse>.Success(result.LoginResponse, "登录成功");
|
||||
}
|
||||
|
||||
_logger.LogWarning("WeChat login failed: {Error}", result.ErrorMessage);
|
||||
return ApiResponse<string>.Fail(result.ErrorMessage ?? "登录失败");
|
||||
return ApiResponse<LoginResponse>.Fail(result.ErrorMessage ?? "登录失败");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -69,28 +69,28 @@ public class AuthController : ControllerBase
|
|||
/// <remarks>
|
||||
/// POST /api/mobileLogin
|
||||
///
|
||||
/// 使用手机号和验证码进行登录,返回JWT Token
|
||||
/// Requirements: 2.1-2.7
|
||||
/// 使用手机号和验证码进行登录,返回双Token(Access Token + Refresh Token)
|
||||
/// Requirements: 1.1, 1.2, 6.1
|
||||
/// </remarks>
|
||||
/// <param name="request">手机号登录请求参数,包含手机号和验证码</param>
|
||||
/// <returns>登录成功返回JWT Token,失败返回错误信息</returns>
|
||||
/// <returns>登录成功返回LoginResponse(包含accessToken、refreshToken、expiresIn),失败返回错误信息</returns>
|
||||
[HttpPost("mobileLogin")]
|
||||
[ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<string>> MobileLogin([FromBody] MobileLoginRequest request)
|
||||
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<LoginResponse>> MobileLogin([FromBody] MobileLoginRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return ApiResponse<string>.Fail("请求参数不能为空");
|
||||
return ApiResponse<LoginResponse>.Fail("请求参数不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Mobile))
|
||||
{
|
||||
return ApiResponse<string>.Fail("手机号不能为空");
|
||||
return ApiResponse<LoginResponse>.Fail("手机号不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
return ApiResponse<string>.Fail("验证码不能为空");
|
||||
return ApiResponse<LoginResponse>.Fail("验证码不能为空");
|
||||
}
|
||||
|
||||
var result = await _authService.MobileLoginAsync(
|
||||
|
|
@ -99,14 +99,94 @@ public class AuthController : ControllerBase
|
|||
request.Pid,
|
||||
request.ClickId);
|
||||
|
||||
if (result.Success)
|
||||
if (result.Success && result.LoginResponse != null)
|
||||
{
|
||||
_logger.LogInformation("Mobile login successful: UserId={UserId}", result.UserId);
|
||||
return ApiResponse<string>.Success(result.Token!, "登录成功");
|
||||
return ApiResponse<LoginResponse>.Success(result.LoginResponse, "登录成功");
|
||||
}
|
||||
|
||||
_logger.LogWarning("Mobile login failed: {Error}", result.ErrorMessage);
|
||||
return ApiResponse<string>.Fail(result.ErrorMessage ?? "登录失败");
|
||||
return ApiResponse<LoginResponse>.Fail(result.ErrorMessage ?? "登录失败");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// POST /api/refresh
|
||||
///
|
||||
/// 使用 Refresh Token 获取新的 Access Token 和 Refresh Token
|
||||
/// Requirements: 2.1, 2.2, 2.3, 2.4
|
||||
/// </remarks>
|
||||
/// <param name="request">刷新请求,包含 Refresh Token</param>
|
||||
/// <returns>刷新成功返回新的 LoginResponse,失败返回错误信息</returns>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<LoginResponse>> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return ApiResponse<LoginResponse>.Fail("刷新令牌不能为空");
|
||||
}
|
||||
|
||||
var clientIp = GetClientIp();
|
||||
var result = await _authService.RefreshTokenAsync(request.RefreshToken, clientIp);
|
||||
|
||||
if (result.Success && result.LoginResponse != null)
|
||||
{
|
||||
_logger.LogInformation("Token refresh successful: UserId={UserId}", result.LoginResponse.UserId);
|
||||
return ApiResponse<LoginResponse>.Success(result.LoginResponse, "刷新成功");
|
||||
}
|
||||
|
||||
_logger.LogWarning("Token refresh failed: {Error}", result.ErrorMessage);
|
||||
|
||||
// 根据错误类型返回不同的状态码
|
||||
// -1 表示未登录/Token无效,前端需要跳转登录页
|
||||
return ApiResponse<LoginResponse>.Fail(result.ErrorMessage ?? "刷新失败", -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退出登录(撤销 Token)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// POST /api/logout
|
||||
///
|
||||
/// 撤销用户的 Refresh Token,使其失效
|
||||
/// Requirements: 4.4
|
||||
/// </remarks>
|
||||
/// <param name="request">退出请求,包含要撤销的 Refresh Token(可选,不传则撤销当前用户所有Token)</param>
|
||||
/// <returns>退出成功返回成功消息</returns>
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse> Logout([FromBody] RefreshTokenRequest? request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
var clientIp = GetClientIp();
|
||||
|
||||
try
|
||||
{
|
||||
if (request != null && !string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
// 撤销指定的 Refresh Token
|
||||
await _authService.RevokeTokenAsync(request.RefreshToken, clientIp);
|
||||
_logger.LogInformation("Token revoked: UserId={UserId}", userId);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
// 撤销用户的所有 Refresh Token
|
||||
await _authService.RevokeAllUserTokensAsync(userId.Value, clientIp);
|
||||
_logger.LogInformation("All tokens revoked: UserId={UserId}", userId);
|
||||
}
|
||||
|
||||
return ApiResponse.Success("退出成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Logout failed: UserId={UserId}, Error={Error}", userId, ex.Message);
|
||||
return ApiResponse.Fail("退出失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -67,4 +67,32 @@ public interface IAuthService
|
|||
/// <param name="mobile">手机号</param>
|
||||
/// <returns>绑定结果</returns>
|
||||
Task<BindMobileResponse> BindMobileH5Async(int userId, string mobile);
|
||||
|
||||
#region Refresh Token 相关方法
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">Refresh Token</param>
|
||||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||||
/// <returns>刷新结果,包含新的 Access Token 和 Refresh Token</returns>
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshToken, string? ipAddress);
|
||||
|
||||
/// <summary>
|
||||
/// 撤销 Token
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">要撤销的 Refresh Token</param>
|
||||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||||
/// <returns>异步任务</returns>
|
||||
Task RevokeTokenAsync(string refreshToken, string? ipAddress);
|
||||
|
||||
/// <summary>
|
||||
/// 撤销用户的所有 Token
|
||||
/// </summary>
|
||||
/// <param name="userId">用户ID</param>
|
||||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||||
/// <returns>异步任务</returns>
|
||||
Task RevokeAllUserTokensAsync(int userId, string? ipAddress);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public class AuthService : IAuthService
|
|||
private readonly IWechatService _wechatService;
|
||||
private readonly IIpLocationService _ipLocationService;
|
||||
private readonly IRedisService _redisService;
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
// Redis key prefixes
|
||||
|
|
@ -27,6 +28,9 @@ public class AuthService : IAuthService
|
|||
private const string SmsCodeKeyPrefix = "sms:code:";
|
||||
private const int DebounceSeconds = 3;
|
||||
|
||||
// Refresh Token 配置
|
||||
private const int RefreshTokenLength = 64;
|
||||
|
||||
public AuthService(
|
||||
HoneyBoxDbContext dbContext,
|
||||
IUserService userService,
|
||||
|
|
@ -34,6 +38,7 @@ public class AuthService : IAuthService
|
|||
IWechatService wechatService,
|
||||
IIpLocationService ipLocationService,
|
||||
IRedisService redisService,
|
||||
JwtSettings jwtSettings,
|
||||
ILogger<AuthService> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
|
|
@ -42,6 +47,7 @@ public class AuthService : IAuthService
|
|||
_wechatService = wechatService ?? throw new ArgumentNullException(nameof(wechatService));
|
||||
_ipLocationService = ipLocationService ?? throw new ArgumentNullException(nameof(ipLocationService));
|
||||
_redisService = redisService ?? throw new ArgumentNullException(nameof(redisService));
|
||||
_jwtSettings = jwtSettings ?? throw new ArgumentNullException(nameof(jwtSettings));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
|
|
@ -146,14 +152,14 @@ public class AuthService : IAuthService
|
|||
}
|
||||
}
|
||||
|
||||
// 1.5 生成JWT Token
|
||||
_logger.LogInformation("[AuthService] 开始生成JWT Token: UserId={UserId}", user.Id);
|
||||
var token = _jwtService.GenerateToken(user);
|
||||
_logger.LogInformation("[AuthService] JWT Token生成成功,长度={Length}", token?.Length ?? 0);
|
||||
// 1.5 生成双 Token(Access Token + Refresh Token)
|
||||
_logger.LogInformation("[AuthService] 开始生成双 Token: UserId={UserId}", user.Id);
|
||||
var loginResponse = await GenerateLoginResponseAsync(user, null);
|
||||
_logger.LogInformation("[AuthService] 双 Token 生成成功,AccessToken长度={Length}", loginResponse.AccessToken?.Length ?? 0);
|
||||
|
||||
// 3.6 同时在数据库UserAccount表中存储account_token用于兼容旧系统
|
||||
_logger.LogInformation("[AuthService] 更新UserAccount表...");
|
||||
await CreateOrUpdateAccountTokenAsync(user.Id, token);
|
||||
await CreateOrUpdateAccountTokenAsync(user.Id, loginResponse.AccessToken);
|
||||
_logger.LogInformation("[AuthService] UserAccount更新成功");
|
||||
|
||||
_logger.LogInformation("[AuthService] 微信登录成功: UserId={UserId}", user.Id);
|
||||
|
|
@ -161,8 +167,9 @@ public class AuthService : IAuthService
|
|||
return new LoginResult
|
||||
{
|
||||
Success = true,
|
||||
Token = token,
|
||||
UserId = user.Id
|
||||
Token = loginResponse.AccessToken, // 兼容旧版
|
||||
UserId = user.Id,
|
||||
LoginResponse = loginResponse
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -253,19 +260,20 @@ public class AuthService : IAuthService
|
|||
_logger.LogInformation("New user created via mobile login: UserId={UserId}, Mobile={Mobile}", user.Id, MaskMobile(mobile));
|
||||
}
|
||||
|
||||
// 2.4 生成JWT Token
|
||||
var token = _jwtService.GenerateToken(user);
|
||||
// 2.4 生成双 Token(Access Token + Refresh Token)
|
||||
var loginResponse = await GenerateLoginResponseAsync(user, null);
|
||||
|
||||
// 3.6 同时在数据库UserAccount表中存储account_token用于兼容旧系统
|
||||
await CreateOrUpdateAccountTokenAsync(user.Id, token);
|
||||
await CreateOrUpdateAccountTokenAsync(user.Id, loginResponse.AccessToken);
|
||||
|
||||
_logger.LogInformation("Mobile login successful: UserId={UserId}", user.Id);
|
||||
|
||||
return new LoginResult
|
||||
{
|
||||
Success = true,
|
||||
Token = token,
|
||||
UserId = user.Id
|
||||
Token = loginResponse.AccessToken, // 兼容旧版
|
||||
UserId = user.Id,
|
||||
LoginResponse = loginResponse
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -545,6 +553,261 @@ public class AuthService : IAuthService
|
|||
}
|
||||
|
||||
|
||||
#region Refresh Token Methods
|
||||
|
||||
/// <summary>
|
||||
/// 生成 Refresh Token 并存储到数据库
|
||||
/// Requirements: 1.4, 1.5, 4.1
|
||||
/// </summary>
|
||||
/// <param name="userId">用户ID</param>
|
||||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||||
/// <returns>生成的 Refresh Token 明文</returns>
|
||||
private async Task<string> GenerateRefreshTokenAsync(int userId, string? ipAddress)
|
||||
{
|
||||
// 生成随机 Refresh Token
|
||||
var refreshToken = GenerateSecureRandomString(RefreshTokenLength);
|
||||
|
||||
// 计算 SHA256 哈希值用于存储
|
||||
var tokenHash = ComputeSha256Hash(refreshToken);
|
||||
|
||||
// 计算过期时间(7天)
|
||||
var expiresAt = DateTime.Now.AddDays(_jwtSettings.RefreshTokenExpirationDays);
|
||||
|
||||
// 创建数据库记录
|
||||
var userRefreshToken = new UserRefreshToken
|
||||
{
|
||||
UserId = userId,
|
||||
TokenHash = tokenHash,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedByIp = ipAddress
|
||||
};
|
||||
|
||||
await _dbContext.UserRefreshTokens.AddAsync(userRefreshToken);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Generated refresh token for user {UserId}, expires at {ExpiresAt}", userId, expiresAt);
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成登录响应(包含双 Token)
|
||||
/// Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
/// </summary>
|
||||
/// <param name="user">用户实体</param>
|
||||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||||
/// <returns>登录响应</returns>
|
||||
private async Task<LoginResponse> GenerateLoginResponseAsync(User user, string? ipAddress)
|
||||
{
|
||||
// 生成 Access Token (JWT)
|
||||
var accessToken = _jwtService.GenerateToken(user);
|
||||
|
||||
// 生成 Refresh Token 并存储
|
||||
var refreshToken = await GenerateRefreshTokenAsync(user.Id, ipAddress);
|
||||
|
||||
// 计算 Access Token 过期时间(秒)
|
||||
var expiresIn = _jwtSettings.ExpirationMinutes * 60;
|
||||
|
||||
return new LoginResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn,
|
||||
UserId = user.Id
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// Requirements: 2.1-2.6
|
||||
/// </summary>
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshToken, string? ipAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
_logger.LogWarning("Refresh token is empty");
|
||||
return RefreshTokenResult.Fail("刷新令牌不能为空");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 计算 Token 哈希值
|
||||
var tokenHash = ComputeSha256Hash(refreshToken);
|
||||
|
||||
// 查找 Token 记录
|
||||
var storedToken = await _dbContext.UserRefreshTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
|
||||
|
||||
if (storedToken == null)
|
||||
{
|
||||
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash.Substring(0, 8) + "...");
|
||||
return RefreshTokenResult.Fail("无效的刷新令牌");
|
||||
}
|
||||
|
||||
// 检查是否已过期
|
||||
if (storedToken.IsExpired)
|
||||
{
|
||||
_logger.LogWarning("Refresh token expired for user {UserId}", storedToken.UserId);
|
||||
return RefreshTokenResult.Fail("刷新令牌已过期");
|
||||
}
|
||||
|
||||
// 检查是否已撤销
|
||||
if (storedToken.IsRevoked)
|
||||
{
|
||||
_logger.LogWarning("Refresh token revoked for user {UserId}", storedToken.UserId);
|
||||
return RefreshTokenResult.Fail("刷新令牌已失效");
|
||||
}
|
||||
|
||||
// 检查用户是否存在且有效
|
||||
var user = storedToken.User;
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User not found for refresh token");
|
||||
return RefreshTokenResult.Fail("用户不存在");
|
||||
}
|
||||
|
||||
if (user.Status == 0)
|
||||
{
|
||||
_logger.LogWarning("User {UserId} is disabled", user.Id);
|
||||
return RefreshTokenResult.Fail("账号已被禁用");
|
||||
}
|
||||
|
||||
// Token 轮换:生成新的 Refresh Token
|
||||
var newRefreshToken = GenerateSecureRandomString(RefreshTokenLength);
|
||||
var newTokenHash = ComputeSha256Hash(newRefreshToken);
|
||||
|
||||
// 撤销旧 Token 并记录关联关系
|
||||
storedToken.RevokedAt = DateTime.Now;
|
||||
storedToken.RevokedByIp = ipAddress;
|
||||
storedToken.ReplacedByToken = newTokenHash;
|
||||
|
||||
// 创建新的 Token 记录
|
||||
var newUserRefreshToken = new UserRefreshToken
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = newTokenHash,
|
||||
ExpiresAt = DateTime.Now.AddDays(_jwtSettings.RefreshTokenExpirationDays),
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedByIp = ipAddress
|
||||
};
|
||||
|
||||
await _dbContext.UserRefreshTokens.AddAsync(newUserRefreshToken);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// 生成新的 Access Token
|
||||
var accessToken = _jwtService.GenerateToken(user);
|
||||
var expiresIn = _jwtSettings.ExpirationMinutes * 60;
|
||||
|
||||
// 更新 UserAccount 表中的 token(兼容旧系统)
|
||||
await CreateOrUpdateAccountTokenAsync(user.Id, accessToken);
|
||||
|
||||
_logger.LogInformation("Token refreshed successfully for user {UserId}", user.Id);
|
||||
|
||||
return RefreshTokenResult.Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = newRefreshToken,
|
||||
ExpiresIn = expiresIn,
|
||||
UserId = user.Id
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing token");
|
||||
return RefreshTokenResult.Fail("刷新令牌失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销 Token
|
||||
/// Requirements: 4.4
|
||||
/// </summary>
|
||||
public async Task RevokeTokenAsync(string refreshToken, string? ipAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
_logger.LogWarning("Cannot revoke empty refresh token");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 计算 Token 哈希值
|
||||
var tokenHash = ComputeSha256Hash(refreshToken);
|
||||
|
||||
// 查找 Token 记录
|
||||
var storedToken = await _dbContext.UserRefreshTokens
|
||||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
|
||||
|
||||
if (storedToken == null)
|
||||
{
|
||||
_logger.LogWarning("Refresh token not found for revocation");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经撤销,直接返回
|
||||
if (storedToken.IsRevoked)
|
||||
{
|
||||
_logger.LogInformation("Refresh token already revoked");
|
||||
return;
|
||||
}
|
||||
|
||||
// 撤销 Token
|
||||
storedToken.RevokedAt = DateTime.Now;
|
||||
storedToken.RevokedByIp = ipAddress;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Refresh token revoked for user {UserId}", storedToken.UserId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error revoking refresh token");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销用户的所有 Token
|
||||
/// Requirements: 4.4
|
||||
/// </summary>
|
||||
public async Task RevokeAllUserTokensAsync(int userId, string? ipAddress)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 查找用户所有有效的 Token
|
||||
var activeTokens = await _dbContext.UserRefreshTokens
|
||||
.Where(t => t.UserId == userId && t.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
if (!activeTokens.Any())
|
||||
{
|
||||
_logger.LogInformation("No active tokens found for user {UserId}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
token.RevokedByIp = ipAddress;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Revoked {Count} tokens for user {UserId}", activeTokens.Count, userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error revoking all tokens for user {UserId}", userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -702,5 +965,31 @@ public class AuthService : IAuthService
|
|||
return cal.GetWeekOfYear(date, System.Globalization.CalendarWeekRule.FirstDay, DayOfWeek.Monday);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算 SHA256 哈希值
|
||||
/// Requirements: 4.1
|
||||
/// </summary>
|
||||
private static string ComputeSha256Hash(string input)
|
||||
{
|
||||
var inputBytes = Encoding.UTF8.GetBytes(input);
|
||||
var hashBytes = SHA256.HashData(inputBytes);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成安全的随机字符串(用于 Refresh Token)
|
||||
/// </summary>
|
||||
private static string GenerateSecureRandomString(int length)
|
||||
{
|
||||
var randomBytes = new byte[length];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(randomBytes);
|
||||
return Convert.ToBase64String(randomBytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.Replace("=", "")
|
||||
.Substring(0, length);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ public class GoodsService : IGoodsService
|
|||
// 更新参与次数(从缓存获取最新值)
|
||||
foreach (var item in cachedResult.Data)
|
||||
{
|
||||
item.JoinCount = await GetJoinCountAsync(item.Id, 1, item.Type);
|
||||
item.JoinCount = await GetJoinCountAsync(item.Id, item.Type);
|
||||
}
|
||||
_logger.LogDebug("商品列表从缓存获取: CacheKey={CacheKey}", cacheKey);
|
||||
return cachedResult;
|
||||
|
|
@ -184,7 +184,7 @@ public class GoodsService : IGoodsService
|
|||
}
|
||||
|
||||
// 获取参与次数
|
||||
dto.JoinCount = await GetJoinCountAsync(g.Id, 1, g.Type);
|
||||
dto.JoinCount = await GetJoinCountAsync(g.Id, g.Type);
|
||||
|
||||
// 设置类型文字
|
||||
if (goodsTypesMap.TryGetValue(g.Type, out var typeText))
|
||||
|
|
@ -321,7 +321,7 @@ public class GoodsService : IGoodsService
|
|||
/// <summary>
|
||||
/// 获取商品参与次数
|
||||
/// </summary>
|
||||
private async Task<int> GetJoinCountAsync(int goodsId, int num, int orderType)
|
||||
private async Task<int> GetJoinCountAsync(int goodsId, int orderType)
|
||||
{
|
||||
// 先从缓存获取
|
||||
var joinCount = await _cacheService.GetJoinCountAsync(goodsId);
|
||||
|
|
@ -331,9 +331,9 @@ public class GoodsService : IGoodsService
|
|||
}
|
||||
|
||||
// 缓存未命中,从数据库查询
|
||||
// 注意:不过滤 num 字段,与 PHP 逻辑保持一致
|
||||
joinCount = await _dbContext.OrderItems
|
||||
.Where(oi => oi.GoodsId == goodsId
|
||||
&& oi.Num == num
|
||||
&& oi.ShangId >= ShangCountIdRange[0]
|
||||
&& oi.ShangId <= ShangCountIdRange[1]
|
||||
&& oi.OrderType == orderType)
|
||||
|
|
@ -467,7 +467,7 @@ public class GoodsService : IGoodsService
|
|||
var joinUsers = await GetJoinUsersAsync(goodsId, goodsNum, goods.Type);
|
||||
|
||||
// 8. 获取参与次数
|
||||
var joinCount = await GetJoinCountAsync(goodsId, goodsNum, goods.Type);
|
||||
var joinCount = await GetJoinCountAsync(goodsId, goods.Type);
|
||||
|
||||
// 9. 获取时间配置
|
||||
var (threeTime, fiveTime) = await GetTimeConfigAsync();
|
||||
|
|
|
|||
|
|
@ -51,8 +51,9 @@ public class ServiceModule : Module
|
|||
var wechatService = c.Resolve<IWechatService>();
|
||||
var ipLocationService = c.Resolve<IIpLocationService>();
|
||||
var redisService = c.Resolve<IRedisService>();
|
||||
var jwtSettings = c.Resolve<JwtSettings>();
|
||||
var logger = c.Resolve<ILogger<AuthService>>();
|
||||
return new AuthService(dbContext, userService, jwtService, wechatService, ipLocationService, redisService, logger);
|
||||
return new AuthService(dbContext, userService, jwtService, wechatService, ipLocationService, redisService, jwtSettings, logger);
|
||||
}).As<IAuthService>().InstancePerLifetimeScope();
|
||||
|
||||
// ========== 用户管理系统服务注册 ==========
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ public partial class HoneyBoxDbContext : DbContext
|
|||
|
||||
public virtual DbSet<GoodsKingRank> GoodsKingRanks { get; set; }
|
||||
|
||||
public virtual DbSet<UserRefreshToken> UserRefreshTokens { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
// Connection string is configured in Program.cs via dependency injection
|
||||
|
|
@ -3419,6 +3421,56 @@ public partial class HoneyBoxDbContext : DbContext
|
|||
.HasColumnName("end_time");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserRefreshToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("pk_user_refresh_tokens");
|
||||
|
||||
entity.ToTable("user_refresh_tokens", tb => tb.HasComment("用户刷新令牌表,存储 Refresh Token 信息用于双 Token 认证机制"));
|
||||
|
||||
entity.HasIndex(e => e.UserId, "ix_user_refresh_tokens_user_id");
|
||||
entity.HasIndex(e => e.TokenHash, "ix_user_refresh_tokens_token_hash");
|
||||
entity.HasIndex(e => e.ExpiresAt, "ix_user_refresh_tokens_expires_at");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasComment("主键ID")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.UserId)
|
||||
.HasComment("用户ID")
|
||||
.HasColumnName("user_id");
|
||||
entity.Property(e => e.TokenHash)
|
||||
.HasMaxLength(256)
|
||||
.HasComment("Token 哈希值(SHA256)")
|
||||
.HasColumnName("token_hash");
|
||||
entity.Property(e => e.ExpiresAt)
|
||||
.HasComment("过期时间")
|
||||
.HasColumnName("expires_at");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("(getdate())")
|
||||
.HasComment("创建时间")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedByIp)
|
||||
.HasMaxLength(50)
|
||||
.HasComment("创建时的 IP 地址")
|
||||
.HasColumnName("created_by_ip");
|
||||
entity.Property(e => e.RevokedAt)
|
||||
.HasComment("撤销时间")
|
||||
.HasColumnName("revoked_at");
|
||||
entity.Property(e => e.RevokedByIp)
|
||||
.HasMaxLength(50)
|
||||
.HasComment("撤销时的 IP 地址")
|
||||
.HasColumnName("revoked_by_ip");
|
||||
entity.Property(e => e.ReplacedByToken)
|
||||
.HasMaxLength(256)
|
||||
.HasComment("被替换的新 Token 哈希值")
|
||||
.HasColumnName("replaced_by_token");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_user_refresh_tokens_users");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HoneyBox.Model.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 用户刷新令牌表,存储 Refresh Token 信息用于双 Token 认证机制
|
||||
/// </summary>
|
||||
public class UserRefreshToken
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键ID
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户ID
|
||||
/// </summary>
|
||||
public int UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token 哈希值(SHA256)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string TokenHash { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时的 IP 地址
|
||||
/// </summary>
|
||||
[MaxLength(50)]
|
||||
public string? CreatedByIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 撤销时间
|
||||
/// </summary>
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 撤销时的 IP 地址
|
||||
/// </summary>
|
||||
[MaxLength(50)]
|
||||
public string? RevokedByIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 被替换的新 Token 哈希值(用于 Token 轮换追踪)
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? ReplacedByToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已过期
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsExpired => DateTime.Now >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// 是否已撤销
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsRevoked => RevokedAt != null;
|
||||
|
||||
/// <summary>
|
||||
/// 是否有效(未过期且未撤销)
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsActive => !IsRevoked && !IsExpired;
|
||||
|
||||
/// <summary>
|
||||
/// 关联的用户
|
||||
/// </summary>
|
||||
[ForeignKey("UserId")]
|
||||
public virtual User User { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
namespace HoneyBox.Model.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 登录响应模型(双 Token 认证)
|
||||
/// </summary>
|
||||
public class LoginResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Access Token (JWT),有效期 30 分钟
|
||||
/// </summary>
|
||||
public string AccessToken { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh Token,有效期 7 天
|
||||
/// </summary>
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Access Token 过期时间(秒)
|
||||
/// </summary>
|
||||
public long ExpiresIn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户ID
|
||||
/// </summary>
|
||||
public int UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧版:返回 token 字段(等同于 AccessToken)
|
||||
/// </summary>
|
||||
public string Token => AccessToken;
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ public class LoginResult
|
|||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JWT Token
|
||||
/// JWT Token(兼容旧版)
|
||||
/// </summary>
|
||||
public string? Token { get; set; }
|
||||
|
||||
|
|
@ -24,4 +24,9 @@ public class LoginResult
|
|||
/// 错误信息
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录响应(双 Token 认证)
|
||||
/// </summary>
|
||||
public LoginResponse? LoginResponse { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace HoneyBox.Model.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Token 刷新请求模型
|
||||
/// </summary>
|
||||
public class RefreshTokenRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Refresh Token
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "刷新令牌不能为空")]
|
||||
public string RefreshToken { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
namespace HoneyBox.Model.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Token 刷新结果模型
|
||||
/// </summary>
|
||||
public class RefreshTokenResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否成功
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录响应(刷新成功时返回)
|
||||
/// </summary>
|
||||
public LoginResponse? LoginResponse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(刷新失败时返回)
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建成功结果
|
||||
/// </summary>
|
||||
public static RefreshTokenResult Ok(LoginResponse response) => new()
|
||||
{
|
||||
Success = true,
|
||||
LoginResponse = response
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 创建失败结果
|
||||
/// </summary>
|
||||
public static RefreshTokenResult Fail(string errorMessage) => new()
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
|
@ -158,13 +158,13 @@ public class ApiResponseFormatTests
|
|||
{
|
||||
OrderTotal = "100.00",
|
||||
Price = "10.00",
|
||||
UserMoney = "50.00",
|
||||
UserIntegral = "100.00",
|
||||
UserMoney2 = "20.00",
|
||||
Money = "50.00",
|
||||
Integral = "100.00",
|
||||
Score = 20.00m,
|
||||
UseMoney = "10.00",
|
||||
UseIntegral = "5.00",
|
||||
UseMoney2 = "3.00",
|
||||
UseCoupon = "2.00"
|
||||
UseMoney2 = 3.00m,
|
||||
CouponPrice = "2.00"
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -173,12 +173,12 @@ public class ApiResponseFormatTests
|
|||
// Assert
|
||||
Assert.Contains("\"order_total\":", json);
|
||||
Assert.Contains("\"price\":", json);
|
||||
Assert.Contains("\"user_money\":", json);
|
||||
Assert.Contains("\"user_integral\":", json);
|
||||
Assert.Contains("\"user_money2\":", json);
|
||||
Assert.Contains("\"money\":", json);
|
||||
Assert.Contains("\"integral\":", json);
|
||||
Assert.Contains("\"score\":", json);
|
||||
Assert.Contains("\"use_money\":", json);
|
||||
Assert.Contains("\"use_integral\":", json);
|
||||
Assert.Contains("\"use_coupon\":", json);
|
||||
Assert.Contains("\"coupon_price\":", json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -520,10 +520,10 @@ public class ApiResponseFormatTests
|
|||
{
|
||||
OrderTotal = "100.00",
|
||||
Price = "10.00",
|
||||
GoodsInfo = new GoodsInfoDto { Id = 1, Title = "Test" },
|
||||
UserMoney = "50.00",
|
||||
UserIntegral = "100.00",
|
||||
UserMoney2 = "20.00"
|
||||
Goods = new GoodsInfoDto { Id = 1, Title = "Test" },
|
||||
Money = "50.00",
|
||||
Integral = "100.00",
|
||||
Score = 20.00m
|
||||
};
|
||||
var response = ApiResponse<OrderCalculationDto>.Success(data);
|
||||
|
||||
|
|
@ -538,10 +538,10 @@ public class ApiResponseFormatTests
|
|||
// Assert - 验证数据字段
|
||||
Assert.Contains("\"order_total\":\"100.00\"", json);
|
||||
Assert.Contains("\"price\":\"10.00\"", json);
|
||||
Assert.Contains("\"goods_info\":", json);
|
||||
Assert.Contains("\"user_money\":\"50.00\"", json);
|
||||
Assert.Contains("\"user_integral\":\"100.00\"", json);
|
||||
Assert.Contains("\"user_money2\":\"20.00\"", json);
|
||||
Assert.Contains("\"goods\":", json);
|
||||
Assert.Contains("\"money\":\"50.00\"", json);
|
||||
Assert.Contains("\"integral\":\"100.00\"", json);
|
||||
Assert.Contains("\"score\":20", json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1083,7 +1083,8 @@ public class LotteryServiceIntegrationTests
|
|||
private LotteryEngine CreateLotteryEngine(HoneyBoxDbContext dbContext, IInventoryManager inventoryManager)
|
||||
{
|
||||
var mockLogger = new Mock<ILogger<LotteryEngine>>();
|
||||
return new LotteryEngine(dbContext, inventoryManager, mockLogger.Object);
|
||||
var mockRewardService = new Mock<IRewardService>();
|
||||
return new LotteryEngine(dbContext, inventoryManager, mockRewardService.Object, mockLogger.Object);
|
||||
}
|
||||
|
||||
private InventoryManager CreateInventoryManager(HoneyBoxDbContext dbContext)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ public class OrderServiceIntegrationTests
|
|||
private OrderService CreateOrderService(HoneyBoxDbContext dbContext)
|
||||
{
|
||||
var mockLogger = new Mock<ILogger<OrderService>>();
|
||||
return new OrderService(dbContext, mockLogger.Object);
|
||||
var mockLotteryEngine = new Mock<ILotteryEngine>();
|
||||
return new OrderService(dbContext, mockLogger.Object, mockLotteryEngine.Object);
|
||||
}
|
||||
|
||||
#region 测试数据准备
|
||||
|
|
@ -151,8 +152,8 @@ public class OrderServiceIntegrationTests
|
|||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("30.00", result.OrderTotal); // 10 * 3 = 30
|
||||
Assert.NotNull(result.GoodsInfo);
|
||||
Assert.Equal(1, result.GoodsInfo.Id);
|
||||
Assert.NotNull(result.Goods);
|
||||
Assert.Equal(1, result.Goods.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -253,7 +254,7 @@ public class OrderServiceIntegrationTests
|
|||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("1500.00", result.UseMoney2); // 使用1500哈尼券
|
||||
Assert.Equal(1500.00m, result.UseMoney2); // 使用1500哈尼券
|
||||
Assert.Equal("15.00", result.Price); // 30 - 15 = 15
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +292,7 @@ public class OrderServiceIntegrationTests
|
|||
// 30 - 25 = 5元
|
||||
Assert.Equal("10.00", result.UseMoney);
|
||||
Assert.Equal("1000.00", result.UseIntegral);
|
||||
Assert.Equal("500.00", result.UseMoney2);
|
||||
Assert.Equal(500.00m, result.UseMoney2);
|
||||
Assert.Equal("5.00", result.Price);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,10 +80,12 @@ public class PaymentNotifyServiceIntegrationTests
|
|||
|
||||
var paymentService = new PaymentService(dbContext, _mockPaymentLogger.Object);
|
||||
|
||||
var mockLotteryEngine = new Mock<ILotteryEngine>();
|
||||
var notifyService = new PaymentNotifyService(
|
||||
dbContext,
|
||||
wechatPayService,
|
||||
paymentService,
|
||||
mockLotteryEngine.Object,
|
||||
_mockNotifyLogger.Object);
|
||||
|
||||
return (notifyService, wechatPayService, paymentService);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ public class WarehouseServiceIntegrationTests
|
|||
{
|
||||
var mockLogger = new Mock<ILogger<WarehouseService>>();
|
||||
var mockLogisticsService = new Mock<ILogisticsService>();
|
||||
return new WarehouseService(dbContext, mockLogger.Object, mockLogisticsService.Object);
|
||||
var mockWechatService = new Mock<IWechatService>();
|
||||
return new WarehouseService(dbContext, mockLogger.Object, mockLogisticsService.Object, mockWechatService.Object);
|
||||
}
|
||||
|
||||
#region 测试数据准备
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ public class AuthServiceBindMobilePropertyTests
|
|||
mockWechatService.Object,
|
||||
mockIpLocationService.Object,
|
||||
mockRedisService.Object,
|
||||
jwtSettings,
|
||||
mockAuthLogger.Object);
|
||||
|
||||
return (authService, mockWechatService, mockRedisService);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ public class AuthServiceLoginRecordPropertyTests
|
|||
mockWechatService.Object,
|
||||
mockIpLocationService.Object,
|
||||
mockRedisService.Object,
|
||||
jwtSettings,
|
||||
mockAuthLogger.Object);
|
||||
|
||||
return (authService, mockIpLocationService);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ public class AuthServicePropertyTests
|
|||
mockWechatService.Object,
|
||||
mockIpLocationService.Object,
|
||||
mockRedisService.Object,
|
||||
jwtSettings,
|
||||
mockAuthLogger.Object);
|
||||
|
||||
return (authService, mockWechatService, mockRedisService);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
using HoneyBox.Core.Interfaces;
|
||||
using HoneyBox.Core.Services;
|
||||
using HoneyBox.Model.Data;
|
||||
using HoneyBox.Model.Models.Auth;
|
||||
using HoneyBox.Model.Models.Payment;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
|
@ -63,6 +65,15 @@ public class WechatServiceTests
|
|||
_mockRedisService = new Mock<IRedisService>();
|
||||
}
|
||||
|
||||
private HoneyBoxDbContext CreateInMemoryDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new HoneyBoxDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOpenIdAsync_WithValidCode_ReturnsSuccessResult()
|
||||
{
|
||||
|
|
@ -83,7 +94,8 @@ public class WechatServiceTests
|
|||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetOpenIdAsync("test_code");
|
||||
|
|
@ -101,7 +113,8 @@ public class WechatServiceTests
|
|||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetOpenIdAsync(string.Empty);
|
||||
|
|
@ -118,7 +131,8 @@ public class WechatServiceTests
|
|||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetOpenIdAsync(null!);
|
||||
|
|
@ -149,7 +163,8 @@ public class WechatServiceTests
|
|||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetOpenIdAsync("invalid_code");
|
||||
|
|
@ -176,7 +191,8 @@ public class WechatServiceTests
|
|||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetOpenIdAsync("test_code");
|
||||
|
|
@ -212,7 +228,8 @@ public class WechatServiceTests
|
|||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetMobileAsync("test_access_token");
|
||||
|
|
@ -229,7 +246,8 @@ public class WechatServiceTests
|
|||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetMobileAsync(string.Empty);
|
||||
|
|
@ -260,7 +278,8 @@ public class WechatServiceTests
|
|||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetMobileAsync("invalid_token");
|
||||
|
|
@ -286,7 +305,8 @@ public class WechatServiceTests
|
|||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object);
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
var wechatService = new WechatService(httpClient, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext);
|
||||
|
||||
// Act
|
||||
var result = await wechatService.GetMobileAsync("test_token");
|
||||
|
|
@ -301,9 +321,12 @@ public class WechatServiceTests
|
|||
[Fact]
|
||||
public void WechatService_WithNullHttpClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WechatService(null!, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object));
|
||||
new WechatService(null!, _mockLogger.Object, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -311,10 +334,11 @@ public class WechatServiceTests
|
|||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient();
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WechatService(httpClient, null!, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object));
|
||||
new WechatService(httpClient, null!, _wechatSettings, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -322,9 +346,10 @@ public class WechatServiceTests
|
|||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient();
|
||||
var dbContext = CreateInMemoryDbContext();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WechatService(httpClient, _mockLogger.Object, null!, _mockWechatPaySettings.Object, _mockRedisService.Object));
|
||||
new WechatService(httpClient, _mockLogger.Object, null!, _mockWechatPaySettings.Object, _mockRedisService.Object, dbContext));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user