This commit is contained in:
zpc 2026-01-25 19:10:31 +08:00
parent bc898cdc98
commit 434fe8f833
47 changed files with 1860 additions and 108 deletions

View 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 环境限制)

View 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_TokenToken 轮换)
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 用于兼容旧系统

View 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 天,可根据需要调整

View File

@ -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": "钻石",

View File

@ -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 {
/**
* 处理退出登录
* 清除所有TokenaccessTokenrefreshTokentokenExpireTime
* 可选调用后端撤销接口
*/
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
}
}
});

View File

@ -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')

View File

@ -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} 刷新结果包含新的accessTokenrefreshTokenexpiresIn
*/
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} 记录结果

View File

@ -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"

View File

@ -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 -->

View File

@ -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)'))

View File

@ -1,5 +1,5 @@
{
"name" : "友达赏王者",
"name" : "哈尼盲盒",
"appid" : "__UNI__C225F9A",
"description" : "",
"versionName" : "1.0.2",

View File

@ -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: {

View File

@ -439,7 +439,7 @@
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "友达赏",
"navigationBarTitleText": "哈尼盲盒",
"navigationBarBackgroundColor": "#222222",
"backgroundColor": "#000000"
},

View File

@ -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
}
},

View File

@ -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'
}
},

View File

@ -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'
}
},

View File

@ -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
}
},

View File

@ -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
}
},

View File

@ -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>

View File

@ -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>

View File

@ -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 }">

View File

@ -208,7 +208,7 @@
subTabCur: 0,
payType: {
1: {
title: "友达币",
title: this.$config.getAppSetting('currency1_name') || "UU币",
icon: "/static/img/pay_type1.png",
},
2: {

View File

@ -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
}
},

View File

@ -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);
// 使TokenToken
saveLoginTokens(res.data);
this.$c.msg("登录成功");
// URL
@ -374,7 +375,8 @@
console.log(res, '登录成功');
if (res.status == 1) {
uni.setStorageSync('token', res.data);
// 使TokenToken
saveLoginTokens(res.data);
this.$c.msg("登录成功");
// URL

View File

@ -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">

View File

@ -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) {

View 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

View File

@ -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进行登录返回双TokenAccess 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
/// 使用手机号和验证码进行登录,返回双TokenAccess 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("退出失败");
}
}

View File

@ -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
}

View File

@ -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 生成双 TokenAccess 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 生成双 TokenAccess 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
}

View File

@ -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();

View File

@ -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();
// ========== 用户管理系统服务注册 ==========

View File

@ -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);
}

View File

@ -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!;
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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!;
}

View File

@ -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
};
}

View File

@ -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>

View File

@ -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)

View File

@ -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);
}

View File

@ -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);

View File

@ -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

View File

@ -59,6 +59,7 @@ public class AuthServiceBindMobilePropertyTests
mockWechatService.Object,
mockIpLocationService.Object,
mockRedisService.Object,
jwtSettings,
mockAuthLogger.Object);
return (authService, mockWechatService, mockRedisService);

View File

@ -56,6 +56,7 @@ public class AuthServiceLoginRecordPropertyTests
mockWechatService.Object,
mockIpLocationService.Object,
mockRedisService.Object,
jwtSettings,
mockAuthLogger.Object);
return (authService, mockIpLocationService);

View File

@ -59,6 +59,7 @@ public class AuthServicePropertyTests
mockWechatService.Object,
mockIpLocationService.Object,
mockRedisService.Object,
jwtSettings,
mockAuthLogger.Object);
return (authService, mockWechatService, mockRedisService);

View File

@ -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));
}
}