Compare commits
5 Commits
16b4300354
...
20260113
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e60f73c2 | |||
| 14489b6afd | |||
| d45f4417ed | |||
| 7b33ab6e2a | |||
| 93c4d7b7d1 |
@@ -51,6 +51,7 @@ type (
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
}
|
||||
EmailLoginRequest {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
Invite string `json:"invite,optional"`
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# 项目加解密使用说明
|
||||
|
||||
本指南介绍了 PPanel Server 项目中使用的加解密机制,主要用于设备端(Device)通信的安全保障。
|
||||
|
||||
## 1. 核心算法
|
||||
项目使用 **AES-256-CBC** 加密算法。
|
||||
- **填充方式**:PKCS7 Padding。
|
||||
- **数据编码**:Base64。
|
||||
|
||||
## 2. 密钥(Key)与初始化向量(IV)生成逻辑
|
||||
|
||||
### 2.1 密钥生成 (Key Generation)
|
||||
密钥由一个预定义的 `SecuritySecret`(简称 Secret)生成:
|
||||
1. 对 Secret 进行 **SHA-256** 哈希。
|
||||
2. 取哈希结果的前 **32 字节** 作为 AES-256 的密钥。
|
||||
|
||||
### 2.2 初始化向量生成 (IV Generation)
|
||||
IV 是动态生成的,以增强安全性:
|
||||
1. 客户端或服务端生成一个随机字符串(Nonce,通常是纳秒级时间戳)。
|
||||
2. 对 Nonce 进行 **MD5** 哈希。
|
||||
3. 将 MD5 结果(十六进制字符串)与 Secret 拼接。
|
||||
4. 对拼接后的字符串按 2.1 节的方式生成密钥逻辑处理,取结果的前 **16 字节** 作为 IV。
|
||||
|
||||
> [!NOTE]
|
||||
> 在 API 通信中,Nonce 字符串通常通过请求参数中的 `time` 字段传递。
|
||||
|
||||
## 3. 身份识别与优先顺序
|
||||
|
||||
服务端通过 `Login-Type` 来识别是否需要进行加解密逻辑(值为 `device` 时触发)。
|
||||
|
||||
### 3.1 识别途径
|
||||
1. **Token 负载 (JWT Claims)**:Token 中包含 `LoginType` (值为 `device`) 和 `DeviceId`。
|
||||
2. **请求头 (Header)**:`Login-Type: device`。
|
||||
|
||||
### 3.2 优先顺序与场景
|
||||
- **已登录场景**:服务端优先从 **Token** 负载中读取 `LoginType`。如果 Token 合法且包含 `LoginType: device`,则启用加解密。
|
||||
- **未登录/登录中场景**:例如 `/v1/auth/login/device` 接口,由于此时没有有效 Token,服务端会检查 **Header** 中的 `Login-Type`。
|
||||
|
||||
> [!TIP]
|
||||
> 为了确保一致性,建议在设备端请求中**始终**携带 `Login-Type: device` 请求头,并在登录后确保存储的 Token 负载中也包含对应信息。
|
||||
|
||||
## 4. 中间件应用 (DeviceMiddleware)
|
||||
|
||||
|
||||
`DeviceMiddleware` 处理 `Login-Type: device` 的请求:
|
||||
- **请求解密**:
|
||||
- 检查 URL 参数或 JSON Body 中的 `data`(加密数据)和 `time`(Nonce)。
|
||||
- 使用配置的 Secret 和 Nonce 解密 `data`。
|
||||
- 将解密后的 JSON 重新注入到请求上下文中。
|
||||
- **响应加密**:
|
||||
- 拦截响应 Body。
|
||||
- 加密 Body 中的 `data` 字段。
|
||||
- 将响应格式化为:
|
||||
```json
|
||||
{
|
||||
"data": "ENCRYPTED_BASE64_STRING",
|
||||
"time": "NONCE_STRING"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Token 负载详情 (JWT Payload)
|
||||
当 `Login-Type` 为 `device` 时,JWT Token 会包含以下自定义字段:
|
||||
- `LoginType`: `"device"`
|
||||
- `DeviceId`: 设备的数据库唯一 ID。
|
||||
|
||||
## 6. 代码示例
|
||||
|
||||
|
||||
### Go 语言 (服务端)
|
||||
参考 [pkg/aes/aes.go](file:///Users/Apple/vpn/ppanel-server/pkg/aes/aes.go)
|
||||
|
||||
```go
|
||||
import pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
|
||||
// 加密
|
||||
encrypt, nonce, err := pkgaes.Encrypt([]byte("plain text"), secret)
|
||||
|
||||
// 解密
|
||||
decrypt, err := pkgaes.Decrypt(cipherText, secret, nonce)
|
||||
```
|
||||
|
||||
### JavaScript (客户端示例)
|
||||
使用 `crypto-js` 库:
|
||||
|
||||
```javascript
|
||||
const CryptoJS = require("crypto-js");
|
||||
|
||||
function getIv(nonce, secret) {
|
||||
const md5Nonce = CryptoJS.MD5(nonce).toString();
|
||||
const ivStr = md5Nonce + secret;
|
||||
const key = CryptoJS.SHA256(ivStr);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 32));
|
||||
}
|
||||
|
||||
function getKey(secret) {
|
||||
const key = CryptoJS.SHA256(secret);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 64));
|
||||
}
|
||||
|
||||
// 加密示例
|
||||
const key = getKey(secret);
|
||||
const iv = getIv(nonce, secret);
|
||||
const encrypted = CryptoJS.AES.encrypt("plain text", key, {
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
console.log(encrypted.toString()); // Base64
|
||||
```
|
||||
|
||||
## 5. 安全建议
|
||||
- 请务必在配置文件中修改默认的 `SecuritySecret`。
|
||||
- 确保 `time` (Nonce) 在每次请求时都是唯一的,以防止重放攻击和频率分析。
|
||||
@@ -15,6 +15,8 @@
|
||||
- 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。
|
||||
- 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go`、`getErrorLogMessageDetailLogic.go`。
|
||||
- 类型:`internal/types/types.go` 新增请求/响应结构。
|
||||
- 安全:详细加解密逻辑见 [加解密说明文档.md](file:///Users/Apple/vpn/ppanel-server/doc/加解密说明文档.md)。
|
||||
|
||||
|
||||
## 进度记录
|
||||
- 2025-12-02:
|
||||
@@ -23,6 +25,9 @@
|
||||
- 完成公共上报接口与限流、去重逻辑;编译验证通过。
|
||||
- 完成管理端列表与详情接口;编译验证通过。
|
||||
- 待办:根据运营需求调整限流阈值与日志保留策略。
|
||||
- 2026-01-08:
|
||||
- 完成「项目加解密使用说明」文档编写,涵盖 AES-256-CBC 实现及中间件逻辑。
|
||||
|
||||
|
||||
## 接口规范
|
||||
- 上报:`POST /v1/common/log/message/report`(详见 `doc/api/log_message_report.md`)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 设备移出和邀请码优化 - 验收报告
|
||||
|
||||
## 修复内容回顾
|
||||
|
||||
### 1. 设备移出后未自动退出
|
||||
- **修复点 1**:在 `bindEmailWithVerificationLogic.go` 中,当设备从一个用户迁移到另一个用户(如绑定邮箱时),立即调用 `KickDevice` 踢出原用户的 WebSocket 连接。
|
||||
- **修复点 2**:在设备迁移时,清理了 Redis 中的设备缓存和 Session 缓存,并从 `user_sessions` 集合中移除了 Session ID。
|
||||
- **修复点 3**:在 `unbindDeviceLogic.go` 中,解绑设备时补充了 `user_sessions` 集合的清理逻辑,确保 Session 被完全移除。
|
||||
|
||||
### 2. 邀请码错误提示不友好
|
||||
- **修复点**:在 `bindInviteCodeLogic.go` 中,捕获 `gorm.ErrRecordNotFound` 错误,并返回错误码 `20009` (InviteCodeError) 和提示 "无邀请码"。
|
||||
|
||||
---
|
||||
|
||||
## 验证结果
|
||||
|
||||
### 自动化验证
|
||||
- [x] 代码编译通过 (`go build ./...`)
|
||||
- [x] 静态检查通过
|
||||
|
||||
### 场景验证(逻辑推演)
|
||||
|
||||
**场景 1:设备B绑定邮箱后被移除**
|
||||
1. 设备B绑定邮箱,执行迁移逻辑。
|
||||
2. `KickDevice(originalUserId, deviceIdentifier)` 被调用 -> 设备B的 WebSocket 连接断开。
|
||||
3. Redis 中 `device:identifier` 和 `session:id` 被删除 -> Token 失效。
|
||||
4. 用户在设备A上操作移除设备B -> `unbindDeviceLogic` 执行 -> 再次尝试踢出和清理(防御性)。
|
||||
5. **结果**:设备B立即离线且无法继续使用。
|
||||
|
||||
**场景 2:输入错误邀请码**
|
||||
1. 调用绑定接口, `FindOneByReferCode` 返回 `RecordNotFound`。
|
||||
2. 逻辑捕获错误,返回 `InviteCodeError`。
|
||||
3. **结果**:前端收到 20009 错误码和 "无邀请码" 提示。
|
||||
|
||||
---
|
||||
|
||||
## 遗留问题 / 注意事项
|
||||
- 无
|
||||
|
||||
## 结论
|
||||
修复已完成,符合预期。
|
||||
@@ -0,0 +1,160 @@
|
||||
# 设备管理系统 Bug 分析 - 最终确认版
|
||||
|
||||
## 场景还原
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
1. **设备A** 最初通过设备登录(DeviceLogin),系统自动创建用户1 + 设备A记录
|
||||
2. **设备B** 最初也通过设备登录,系统自动创建用户2 + 设备B记录
|
||||
3. **设备A** 绑定邮箱 xxx@example.com,用户1变为"邮箱+设备"用户
|
||||
4. **设备B** 绑定**同一个邮箱** xxx@example.com
|
||||
- 系统发现邮箱已存在,执行设备转移
|
||||
- 设备B 从用户2迁移到用户1
|
||||
- 用户2被删除
|
||||
- 现在用户1拥有:设备A + 设备B + 邮箱认证
|
||||
|
||||
5. **在设备A上操作**,从设备列表移除设备B
|
||||
6. **问题**:设备B没有被踢下线,仍然能使用
|
||||
|
||||
---
|
||||
|
||||
## 数据流分析
|
||||
|
||||
### 绑定邮箱后的状态(第4步后)
|
||||
|
||||
```
|
||||
User 表:
|
||||
┌─────┬───────────────┐
|
||||
│ Id │ 用户1 │
|
||||
└─────┴───────────────┘
|
||||
|
||||
user_device 表:
|
||||
┌─────────────┬───────────┐
|
||||
│ Identifier │ UserId │
|
||||
├─────────────┼───────────┤
|
||||
│ device-a │ 用户1 │
|
||||
│ device-b │ 用户1 │ <- 设备B迁移到用户1
|
||||
└─────────────┴───────────┘
|
||||
|
||||
user_auth_methods 表:
|
||||
┌────────────┬────────────────┬───────────┐
|
||||
│ AuthType │ AuthIdentifier │ UserId │
|
||||
├────────────┼────────────────┼───────────┤
|
||||
│ device │ device-a │ 用户1 │
|
||||
│ device │ device-b │ 用户1 │
|
||||
│ email │ xxx@email.com │ 用户1 │
|
||||
└────────────┴────────────────┴───────────┘
|
||||
|
||||
DeviceManager (内存 WebSocket 连接):
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ userDevices sync.Map │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ 用户1 -> [Device{DeviceID="device-a", ...}] │
|
||||
│ 用户2 -> [Device{DeviceID="device-b", ...}] ❌ │ <- 问题!设备B的连接仍在用户2名下
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 问题根源
|
||||
|
||||
**设备B绑定邮箱时**(`bindEmailWithVerificationLogic.go`):
|
||||
- ✅ 数据库:设备B的 `UserId` 被更新为用户1
|
||||
- ❌ 内存:`DeviceManager` 中设备B的 WebSocket 连接仍然在**用户2**名下
|
||||
- ❌ 缓存:`device:device-b` -> 旧的 sessionId(可能关联用户2)
|
||||
|
||||
**解绑设备B时**(`unbindDeviceLogic.go`):
|
||||
```go
|
||||
// 第 48 行:验证设备属于当前用户
|
||||
if device.UserId != u.Id { // device.UserId=用户1, u.Id=用户1, 验证通过
|
||||
return errors.Wrapf(...)
|
||||
}
|
||||
|
||||
// 第 123 行:踢出设备
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
|
||||
// KickDevice(用户1, "device-b")
|
||||
```
|
||||
|
||||
**KickDevice 执行时**:
|
||||
```go
|
||||
func (dm *DeviceManager) KickDevice(userID int64, deviceID string) {
|
||||
val, ok := dm.userDevices.Load(userID) // 查找用户1的设备列表
|
||||
// 用户1的设备列表只有 device-a
|
||||
// 找不到 device-b!因为 device-b 的连接还在用户2名下
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 根本原因总结
|
||||
|
||||
| 操作 | 数据库 | DeviceManager 内存 | Redis 缓存 |
|
||||
|------|--------|-------------------|------------|
|
||||
| 设备B绑定邮箱 | ✅ 更新 UserId | ❌ 未更新 | ❌ 未清理 |
|
||||
| 解绑设备B | ✅ 创建新用户 | ❌ 找不到设备 | ✅ 尝试清理 |
|
||||
|
||||
**核心问题**:设备绑定邮箱(转移用户)时,没有更新 `DeviceManager` 中的连接归属。
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 方案1:在绑定邮箱时踢出旧连接(推荐)
|
||||
|
||||
在 `bindEmailWithVerificationLogic.go` 迁移设备后,踢出设备的旧连接:
|
||||
|
||||
```go
|
||||
// 迁移设备到邮箱用户后
|
||||
for _, device := range devices {
|
||||
// 更新设备归属
|
||||
device.UserId = emailUserId
|
||||
err = l.svcCtx.UserModel.UpdateDevice(l.ctx, device)
|
||||
|
||||
// 新增:踢出旧连接(使用原用户ID)
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, device.Identifier)
|
||||
}
|
||||
```
|
||||
|
||||
### 方案2:在解绑时遍历所有用户查找设备
|
||||
|
||||
修改 `KickDevice` 或 `unbindDeviceLogic` 逻辑,不依赖用户ID查找设备。
|
||||
|
||||
### 方案3:清理 Redis 缓存使旧 Token 失效
|
||||
|
||||
确保设备转移后,旧的 session 和 device 缓存被清理:
|
||||
|
||||
```go
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, _ := l.svcCtx.Redis.Get(ctx, deviceCacheKey).Result(); sessionId != "" {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
l.svcCtx.Redis.Del(ctx, deviceCacheKey, sessionIdCacheKey)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 推荐修复策略
|
||||
|
||||
**双管齐下**:
|
||||
|
||||
1. **修复 `bindEmailWithVerificationLogic.go`**:
|
||||
- 设备转移后立即踢出旧连接
|
||||
- 清理旧用户的缓存
|
||||
|
||||
2. **修复 `unbindDeviceLogic.go`**(防御性编程):
|
||||
- 补充 `user_sessions` 清理逻辑(参考 `deleteUserDeviceLogic.go`)
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `internal/logic/public/user/bindEmailWithVerificationLogic.go` | 设备转移后踢出旧连接 |
|
||||
| `internal/logic/public/user/unbindDeviceLogic.go` | 补充 user_sessions 清理 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 设备B绑定邮箱后,设备B的旧连接被踢出
|
||||
2. 从设备A解绑设备B后,设备B立即被踢下线
|
||||
3. 设备B的 Token 失效,无法继续调用 API
|
||||
@@ -0,0 +1,117 @@
|
||||
# 设备移出和邀请码优化 - 共识文档(更新版)
|
||||
|
||||
## 需求概述
|
||||
|
||||
修复两个 Bug:
|
||||
1. **Bug 1**:设备B绑定邮箱后被从设备A移除,设备B没有被踢下线
|
||||
2. **Bug 2**:输入不存在的邀请码时,提示信息不友好
|
||||
|
||||
---
|
||||
|
||||
## Bug 1:设备移出后未自动退出
|
||||
|
||||
### 根本原因
|
||||
|
||||
设备B绑定邮箱(迁移到邮箱用户)时:
|
||||
- ✅ 数据库更新了设备的 `UserId`
|
||||
- ❌ `DeviceManager` 内存中设备B的 WebSocket 连接仍在**原用户**名下
|
||||
- ❌ Redis 缓存中设备B的 session 未被清理
|
||||
|
||||
解绑设备B时,`KickDevice(用户1, "device-b")` 在用户1的设备列表中找不到 device-b(因为连接还在原用户名下)。
|
||||
|
||||
### 修复方案
|
||||
|
||||
**文件1:`bindEmailWithVerificationLogic.go`**
|
||||
|
||||
在设备迁移后,踢出旧连接并清理缓存:
|
||||
|
||||
```go
|
||||
// 第 139-158 行之后添加
|
||||
for _, device := range devices {
|
||||
device.UserId = emailUserId
|
||||
err = l.svcCtx.UserModel.UpdateDevice(l.ctx, device)
|
||||
// ...existing code...
|
||||
|
||||
// 新增:踢出旧连接并清理缓存
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, device.Identifier)
|
||||
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, _ := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); sessionId != "" {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, deviceCacheKey).Err()
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**文件2:`unbindDeviceLogic.go`**(防御性修复)
|
||||
|
||||
补充 `user_sessions` 清理逻辑,与 `deleteUserDeviceLogic.go` 保持一致:
|
||||
|
||||
```go
|
||||
// 第 118-122 行,补充 sessionsKey 清理
|
||||
if sessionId, rerr := l.svcCtx.Redis.Get(ctx, deviceCacheKey).Result(); rerr == nil && sessionId != "" {
|
||||
_ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err()
|
||||
// 新增:清理 user_sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, device.UserId)
|
||||
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 2:邀请码错误提示不友好
|
||||
|
||||
### 根本原因
|
||||
|
||||
`bindInviteCodeLogic.go` 中未区分"邀请码不存在"和"数据库错误"。
|
||||
|
||||
### 修复方案
|
||||
|
||||
```go
|
||||
// 第 44-47 行修改为
|
||||
referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "无邀请码"), "invite code not found")
|
||||
}
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件汇总
|
||||
|
||||
| 文件 | 修改类型 | 优先级 |
|
||||
|------|----------|--------|
|
||||
| `internal/logic/public/user/bindEmailWithVerificationLogic.go` | 核心修复 | 高 |
|
||||
| `internal/logic/public/user/unbindDeviceLogic.go` | 防御性修复 | 中 |
|
||||
| `internal/logic/public/user/bindInviteCodeLogic.go` | Bug 修复 | 中 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### Bug 1 验收
|
||||
- [ ] 设备B绑定邮箱后,设备B的旧 Token 失效
|
||||
- [ ] 设备B绑定邮箱后,设备B的 WebSocket 连接被断开
|
||||
- [ ] 在设备A上移除设备B后,设备B立即被踢下线
|
||||
- [ ] 设备B无法继续使用旧 Token 调用 API
|
||||
|
||||
### Bug 2 验收
|
||||
- [ ] 输入不存在的邀请码时,返回错误码 20009
|
||||
- [ ] 错误消息显示"无邀请码"
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
1. **编译验证**:`go build ./...`
|
||||
2. **手动测试**:
|
||||
- 设备B绑定邮箱 → 检查是否被踢下线
|
||||
- 设备A移除设备B → 检查设备B是否被踢下线
|
||||
- 输入无效邀请码 → 检查错误提示
|
||||
@@ -0,0 +1,96 @@
|
||||
# 设备移出和邀请码优化 - 设计文档
|
||||
|
||||
## 整体架构
|
||||
|
||||
本次修复涉及两个独立的 bug,不需要修改架构,只需要修改具体的业务逻辑层代码。
|
||||
|
||||
### 组件关系图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "用户请求"
|
||||
A[客户端] --> B[HTTP Handler]
|
||||
end
|
||||
|
||||
subgraph "业务逻辑层"
|
||||
B --> C[unbindDeviceLogic]
|
||||
B --> D[bindInviteCodeLogic]
|
||||
end
|
||||
|
||||
subgraph "服务层"
|
||||
C --> E[DeviceManager.KickDevice]
|
||||
D --> F[UserModel.FindOneByReferCode]
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
E --> G[WebSocket连接管理]
|
||||
F --> H[GORM/数据库]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块详细设计
|
||||
|
||||
### 模块1: UnbindDeviceLogic 修复
|
||||
|
||||
#### 当前数据流
|
||||
```
|
||||
1. 用户请求解绑设备
|
||||
2. 验证设备属于当前用户 (device.UserId == u.Id) ✅
|
||||
3. 事务中:创建新用户,迁移设备
|
||||
4. 调用 KickDevice(u.Id, identifier) ❌ <-- 用户ID错误
|
||||
```
|
||||
|
||||
#### 修复后数据流
|
||||
```
|
||||
1. 用户请求解绑设备
|
||||
2. 验证设备属于当前用户 ✅
|
||||
3. 保存原始用户ID: originalUserId := device.UserId ✅
|
||||
4. 事务中:创建新用户,迁移设备
|
||||
5. 调用 KickDevice(originalUserId, identifier) ✅ <-- 使用正确的用户ID
|
||||
```
|
||||
|
||||
#### 接口契约
|
||||
无变化,仅修改内部实现。
|
||||
|
||||
---
|
||||
|
||||
### 模块2: BindInviteCodeLogic 修复
|
||||
|
||||
#### 当前错误处理
|
||||
```go
|
||||
if err != nil {
|
||||
return xerr.DatabaseQueryError // 所有错误统一处理
|
||||
}
|
||||
```
|
||||
|
||||
#### 修复后错误处理
|
||||
```go
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.InviteCodeError("无邀请码") // 记录不存在 → 友好提示
|
||||
}
|
||||
return xerr.DatabaseQueryError // 其他错误保持原样
|
||||
}
|
||||
```
|
||||
|
||||
#### 接口契约
|
||||
API 返回格式不变,但错误码从 `10001` 变为 `20009`(针对邀请码不存在的情况)。
|
||||
|
||||
---
|
||||
|
||||
## 异常处理策略
|
||||
|
||||
| 场景 | 错误码 | 错误消息 |
|
||||
|------|--------|----------|
|
||||
| 邀请码不存在 | 20009 | 无邀请码 |
|
||||
| 数据库查询错误 | 10001 | Database query error |
|
||||
| 绑定自己的邀请码 | 20009 | 不允许绑定自己 |
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
1. **最小改动原则**:只修改必要的代码,不重构现有逻辑
|
||||
2. **向后兼容**:不改变 API 接口定义
|
||||
3. **代码风格一致**:遵循项目现有的错误处理模式
|
||||
@@ -0,0 +1,20 @@
|
||||
# 设备移出和邀请码优化 - 项目总结
|
||||
|
||||
## 项目概览
|
||||
本次任务修复了两个影响用户体验的 Bug:
|
||||
1. 设备绑定邮箱后,从设备列表移除时未自动退出。
|
||||
2. 绑定无效邀请码时,错误提示不友好。
|
||||
|
||||
## 关键变更
|
||||
1. **核心修复**:在设备归属转移(绑定邮箱)时,主动踢出原用户的 WebSocket 连接,防止“幽灵连接”存在。
|
||||
2. **安全增强**:在设备解绑和转移时,彻底清理 Redis 中的 Session 缓存(包括 `user_sessions` 集合)。
|
||||
3. **体验优化**:优化了邀请码验证的错误提示,明确告知用户“无邀请码”。
|
||||
|
||||
## 文件变更列表
|
||||
- `internal/logic/public/user/bindEmailWithVerificationLogic.go`
|
||||
- `internal/logic/public/user/unbindDeviceLogic.go`
|
||||
- `internal/logic/public/user/bindInviteCodeLogic.go`
|
||||
|
||||
## 后续建议
|
||||
- 建议在测试环境中重点测试多端登录和设备绑定的边界情况。
|
||||
- 关注 `DeviceManager` 的内存使用情况,确保大量的踢出操作不会造成锁竞争。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 设备移出和邀请码优化 - 任务清单
|
||||
|
||||
## 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[任务1: 修复设备踢出Bug] --> C[任务3: 编译验证]
|
||||
B[任务2: 修复邀请码提示Bug] --> C
|
||||
C --> D[任务4: 更新文档]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 原子任务列表
|
||||
|
||||
### 任务1: 修复设备解绑后未踢出的问题
|
||||
|
||||
**输入契约**:
|
||||
- 文件:`internal/logic/public/user/unbindDeviceLogic.go`
|
||||
- 当前代码行:第 123 行
|
||||
|
||||
**输出契约**:
|
||||
- 在事务执行前保存 `device.UserId`
|
||||
- 修改 `KickDevice` 调用,使用保存的原始用户ID
|
||||
|
||||
**实现约束**:
|
||||
- 不修改方法签名
|
||||
- 不影响事务逻辑
|
||||
|
||||
**验收标准**:
|
||||
- [x] 代码编译通过
|
||||
- [ ] 解绑设备后,被解绑设备收到踢出消息
|
||||
|
||||
**预估复杂度**:低
|
||||
|
||||
---
|
||||
|
||||
### 任务2: 修复邀请码错误提示不友好的问题
|
||||
|
||||
**输入契约**:
|
||||
- 文件:`internal/logic/public/user/bindInviteCodeLogic.go`
|
||||
- 当前代码行:第 44-47 行
|
||||
|
||||
**输出契约**:
|
||||
- 添加 `gorm.ErrRecordNotFound` 判断
|
||||
- 返回友好的错误消息 "无邀请码"
|
||||
- 使用 `xerr.InviteCodeError` 错误码
|
||||
|
||||
**实现约束**:
|
||||
- 保持与其他模块(如 `userRegisterLogic`)的错误处理风格一致
|
||||
- 需要添加 `gorm.io/gorm` 导入
|
||||
|
||||
**验收标准**:
|
||||
- [x] 代码编译通过
|
||||
- [ ] 输入不存在的邀请码时返回 "无邀请码" 提示
|
||||
|
||||
**预估复杂度**:低
|
||||
|
||||
---
|
||||
|
||||
### 任务3: 编译验证
|
||||
|
||||
**输入契约**:
|
||||
- 任务1和任务2已完成
|
||||
|
||||
**输出契约**:
|
||||
- 项目编译成功,无错误
|
||||
|
||||
**验收标准**:
|
||||
- [x] `go build ./...` 无报错
|
||||
|
||||
---
|
||||
|
||||
### 任务4: 更新说明文档
|
||||
|
||||
**输入契约**:
|
||||
- 任务3已完成
|
||||
|
||||
**输出契约**:
|
||||
- 更新 `说明文档.md` 记录本次修复
|
||||
|
||||
**验收标准**:
|
||||
- [x] 文档记录完整
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. ✅ 任务1 和 任务2 可并行执行(无依赖)
|
||||
2. ✅ 任务3 在任务1、2完成后执行
|
||||
3. ✅ 任务4 最后执行
|
||||
@@ -116,7 +116,7 @@ VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-2
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
|
||||
(38, 'verify_code', 'VerifyCodeExpireTime', '900', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
func Telegram(svc *svc.ServiceContext) {
|
||||
logger.Infof("Telegram Config Enable: %v", svc.Config.Telegram.Enable)
|
||||
if !svc.Config.Telegram.Enable {
|
||||
logger.Info("Telegram disabled, skipping initialization")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ func Telegram(svc *svc.ServiceContext) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Initializing Telegram Bot API...")
|
||||
bot, err := tgbotapi.NewBotAPI(usedToken)
|
||||
if err != nil {
|
||||
logger.Error("[Init Telegram Config] New Bot API Error: ", logger.Field("error", err.Error()))
|
||||
|
||||
@@ -226,7 +226,7 @@ type TLS struct {
|
||||
}
|
||||
|
||||
type VerifyCode struct {
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"300"`
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"900"`
|
||||
Limit int64 `yaml:"Limit" default:"15"`
|
||||
Interval int64 `yaml:"Interval" default:"60"`
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code {
|
||||
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
@@ -200,6 +200,28 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
)
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
var deviceId int64
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
} else {
|
||||
// Query device info to get DeviceId
|
||||
if device, dErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier); dErr == nil {
|
||||
deviceId = device.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Login (Generate Token)
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
@@ -213,6 +235,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
jwt.WithOption("UserId", userInfo.Id),
|
||||
jwt.WithOption("SessionId", sessionId),
|
||||
jwt.WithOption("LoginType", req.LoginType),
|
||||
jwt.WithOption("DeviceId", deviceId),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||
|
||||
@@ -83,6 +83,12 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
||||
l.Errorw("Verification code error", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code error"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
|
||||
}
|
||||
// 校验有效期(15分钟)
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
l.Errorw("Verification code expired", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code expired"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
}
|
||||
|
||||
// Check user
|
||||
@@ -110,20 +116,20 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
var deviceId int64
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
@@ -104,6 +105,11 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
} else {
|
||||
// Query device info to get DeviceId
|
||||
if device, dErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier); dErr == nil {
|
||||
deviceId = device.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
@@ -119,6 +125,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
jwt.WithOption("UserId", userInfo.Id),
|
||||
jwt.WithOption("SessionId", sessionId),
|
||||
jwt.WithOption("LoginType", req.LoginType),
|
||||
jwt.WithOption("DeviceId", deviceId),
|
||||
)
|
||||
if err != nil {
|
||||
l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error()))
|
||||
|
||||
@@ -77,6 +77,11 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
if payload.Code != req.Code {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
// 校验有效期(15分钟)
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
}
|
||||
// Check if the user exists
|
||||
_, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
@@ -127,20 +132,20 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
return nil
|
||||
})
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
||||
"Type": req.Type,
|
||||
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
||||
"SiteName": l.svcCtx.Config.Site.SiteName,
|
||||
"Expire": 5,
|
||||
"Expire": l.svcCtx.Config.VerifyCode.ExpireTime / 60,
|
||||
"Code": code,
|
||||
}
|
||||
// Save to Redis
|
||||
@@ -101,7 +101,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
||||
}
|
||||
// Marshal the payload
|
||||
val, _ := json.Marshal(payload)
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*IntervalTime*5).Err(); err != nil {
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*time.Duration(l.svcCtx.Config.VerifyCode.ExpireTime)).Err(); err != nil {
|
||||
l.Errorw("[SendEmailCode]: Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/device"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MockEmailModel struct {
|
||||
MockUserModel
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) FindUserAuthMethods(ctx context.Context, userId int64) ([]*user.AuthMethods, error) {
|
||||
return []*user.AuthMethods{
|
||||
{UserId: userId, AuthType: "device", AuthIdentifier: "device-1", Verified: true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) FindUserAuthMethodByOpenID(ctx context.Context, method, openID string) (*user.AuthMethods, error) {
|
||||
if openID == "test@example.com" {
|
||||
// 返回已存在的用户(不同的UserId)
|
||||
return &user.AuthMethods{Id: 99, UserId: 2, AuthType: "email", AuthIdentifier: openID}, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) QueryDeviceList(ctx context.Context, userId int64) ([]*user.Device, int64, error) {
|
||||
// 模拟当前用户(User 1)持有设备 device-1
|
||||
if userId == 1 {
|
||||
return []*user.Device{
|
||||
{Id: 10, UserId: 1, Identifier: "device-1", Enabled: true},
|
||||
}, 1, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) UpdateDevice(ctx context.Context, data *user.Device, tx ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 模拟 Transaction 失败,以便在 KickDevice 后停止
|
||||
func (m *MockEmailModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
|
||||
return fmt.Errorf("stop testing here")
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) FindOne(ctx context.Context, id int64) (*user.User, error) {
|
||||
return &user.User{Id: id}, nil
|
||||
}
|
||||
|
||||
func TestBindEmailWithVerification_KickDevice(t *testing.T) {
|
||||
// 1. Redis Mock
|
||||
mr, err := miniredis.Run()
|
||||
assert.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
// 准备验证码数据
|
||||
email := "test@example.com"
|
||||
code := "123456"
|
||||
payload := map[string]interface{}{
|
||||
"code": code,
|
||||
"lastAt": time.Now().Unix(),
|
||||
}
|
||||
bytes, _ := json.Marshal(payload)
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Register.String(), email)
|
||||
rdb.Set(context.Background(), cacheKey, string(bytes), time.Minute)
|
||||
|
||||
// 2. DeviceManager Mock
|
||||
// 启动 WebSocket 服务器以获取真实连接
|
||||
var serverConn *websocket.Conn
|
||||
connDone := make(chan struct{})
|
||||
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upgrader := websocket.Upgrader{}
|
||||
c, _ := upgrader.Upgrade(w, r, nil)
|
||||
serverConn = c
|
||||
close(connDone)
|
||||
// 保持连接直到测试结束 (read loop)
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
// 客户端连接
|
||||
wsURL := "ws" + strings.TrimPrefix(s.URL, "http")
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
assert.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
<-connDone // 等待服务端获取连接
|
||||
|
||||
dm := device.NewDeviceManager(10, 10)
|
||||
|
||||
// 注入设备 (UserId=1, DeviceId="device-1")
|
||||
dev := &device.Device{
|
||||
Session: "session-1",
|
||||
DeviceID: "device-1",
|
||||
Conn: serverConn,
|
||||
}
|
||||
|
||||
// 使用反射注入
|
||||
v := reflect.ValueOf(dm).Elem()
|
||||
f := v.FieldByName("userDevices")
|
||||
// 直接获取指针
|
||||
userDevicesMap := (*sync.Map)(unsafe.Pointer(f.UnsafeAddr()))
|
||||
userDevicesMap.Store(int64(1), []*device.Device{dev})
|
||||
|
||||
// 3. User Mock
|
||||
mockModel := &MockEmailModel{}
|
||||
// 初始化内部 map,虽然这里只用到 override 的方法
|
||||
mockModel.users = make(map[int64]*user.User)
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
UserModel: mockModel,
|
||||
Redis: rdb,
|
||||
DeviceManager: dm,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{ExpireTime: 900}, // Correct type
|
||||
JwtAuth: config.JwtAuth{MaxSessionsPerUser: 10},
|
||||
},
|
||||
}
|
||||
|
||||
// 4. Run Logic
|
||||
currentUser := &user.User{Id: 1} // 当前用户
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, currentUser)
|
||||
l := NewBindEmailWithVerificationLogic(ctx, svcCtx)
|
||||
|
||||
req := &types.BindEmailWithVerificationRequest{
|
||||
Email: email,
|
||||
Code: code,
|
||||
}
|
||||
|
||||
// 执行
|
||||
_, err = l.BindEmailWithVerification(req)
|
||||
// 我们预期这里会返回错误 ("stop testing here")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "stop testing here")
|
||||
|
||||
// 5. Verify
|
||||
// 验证设备是否被移除 (KickDevice 会从 userDevices 中移除被踢出的设备)
|
||||
val, ok := userDevicesMap.Load(int64(1))
|
||||
|
||||
if ok {
|
||||
// 如果 key 还在,检查列表是否为空
|
||||
devices := val.([]*device.Device)
|
||||
assert.Empty(t, devices, "设备列表应为空 (KickDevice 应该移除设备)")
|
||||
} else {
|
||||
// key 不存在,说明已移除,符合预期
|
||||
}
|
||||
}
|
||||
@@ -56,28 +56,25 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
scenes = []string{constant.Security.String(), constant.Register.String()}
|
||||
verified = false
|
||||
)
|
||||
if req.Code == "202511" {
|
||||
verified = true
|
||||
} else {
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if getErr != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal([]byte(value), &p); err != nil {
|
||||
continue
|
||||
}
|
||||
if p.Code == req.Code {
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
|
||||
verified = true
|
||||
break
|
||||
}
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if getErr != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal([]byte(value), &p); err != nil {
|
||||
continue
|
||||
}
|
||||
// 校验验证码及有效期(15分钟)
|
||||
if p.Code == req.Code && time.Now().Unix()-p.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
|
||||
verified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
|
||||
}
|
||||
|
||||
// 获取当前用户的设备标识符
|
||||
@@ -144,6 +141,8 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
l.Errorw("查询用户设备列表失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "查询用户设备列表失败")
|
||||
}
|
||||
// 保存原用户ID,用于踢出旧连接
|
||||
originalUserId := u.Id
|
||||
for _, device := range devices {
|
||||
// 删除原本的设备记录
|
||||
// err = l.svcCtx.UserModel.DeleteDevice(l.ctx, device.Id)
|
||||
@@ -158,6 +157,27 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
l.Errorw("更新邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "更新原本的设备记录失败")
|
||||
}
|
||||
|
||||
// 踢出设备的旧 WebSocket 连接(使用原用户ID)
|
||||
l.svcCtx.DeviceManager.KickDevice(originalUserId, device.Identifier)
|
||||
l.Infow("已踢出设备旧连接",
|
||||
logger.Field("device_identifier", device.Identifier),
|
||||
logger.Field("original_user_id", originalUserId),
|
||||
logger.Field("new_user_id", emailUserId))
|
||||
|
||||
// 清理设备相关的 Redis 缓存
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, rerr := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); rerr == nil && sessionId != "" {
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err()
|
||||
// 清理 user_sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, originalUserId)
|
||||
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
|
||||
l.Infow("已清理设备缓存",
|
||||
logger.Field("device_identifier", device.Identifier),
|
||||
logger.Field("session_id", sessionId))
|
||||
}
|
||||
}
|
||||
// 再次更新 user_auth_method : 因为之前 默认 设备登录的时候 创建了一个设备认证数据
|
||||
// 现在需要 更新 为 邮箱认证
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BindInviteCodeLogic struct {
|
||||
@@ -43,6 +44,9 @@ func (l *BindInviteCodeLogic) BindInviteCode(req *types.BindInviteCodeRequest) e
|
||||
// 查找邀请人
|
||||
referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "无邀请码"), "invite code not found")
|
||||
}
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MockUserModel 只实现 bindInviteCodeLogic 需要的方法
|
||||
type MockUserModel struct {
|
||||
user.Model // 为了满足接口定义,嵌入 user.Model,未实现的方法会 panic
|
||||
users map[int64]*user.User
|
||||
}
|
||||
|
||||
func (m *MockUserModel) FindOneByReferCode(ctx context.Context, referCode string) (*user.User, error) {
|
||||
for _, u := range m.users {
|
||||
if u.ReferCode == referCode {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (m *MockUserModel) Update(ctx context.Context, data *user.User, tx ...*gorm.DB) error {
|
||||
m.users[data.Id] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBindInviteCodeLogic_BindInviteCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currentUser user.User // 使用值类型,在 Run 中取地址,避免共享
|
||||
initUsers map[int64]*user.User
|
||||
inviteCode string
|
||||
expectError bool
|
||||
expectedCode uint32
|
||||
expectedMsg string
|
||||
}{
|
||||
{
|
||||
name: "成功绑定邀请码",
|
||||
currentUser: user.User{Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
initUsers: map[int64]*user.User{
|
||||
1: {Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
2: {Id: 2, ReferCode: "CODE2", RefererId: 0},
|
||||
},
|
||||
inviteCode: "CODE2",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "邀请码不存在",
|
||||
currentUser: user.User{Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
initUsers: map[int64]*user.User{
|
||||
1: {Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
},
|
||||
inviteCode: "INVALID",
|
||||
expectError: true,
|
||||
expectedCode: xerr.InviteCodeError,
|
||||
expectedMsg: "无邀请码",
|
||||
},
|
||||
{
|
||||
name: "不允许绑定自己",
|
||||
currentUser: user.User{Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
initUsers: map[int64]*user.User{
|
||||
1: {Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
},
|
||||
inviteCode: "CODE1",
|
||||
expectError: true,
|
||||
expectedCode: xerr.InviteCodeError,
|
||||
expectedMsg: "不允许绑定自己",
|
||||
},
|
||||
{
|
||||
name: "用户已经绑定过",
|
||||
currentUser: user.User{Id: 3, ReferCode: "CODE3", RefererId: 2},
|
||||
initUsers: map[int64]*user.User{
|
||||
3: {Id: 3, ReferCode: "CODE3", RefererId: 2},
|
||||
2: {Id: 2, ReferCode: "CODE2", RefererId: 0},
|
||||
},
|
||||
inviteCode: "CODE2",
|
||||
expectError: true,
|
||||
expectedCode: xerr.UserExist,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 初始化 Mock 数据
|
||||
mockModel := &MockUserModel{
|
||||
users: tt.initUsers,
|
||||
}
|
||||
svcCtx := &svc.ServiceContext{
|
||||
UserModel: mockModel,
|
||||
}
|
||||
|
||||
// 确保 User 对象在 Mock DB 中也存在(Update操作需要)
|
||||
// 其实 MockUserModel.Update 会更新 map,所以这里不需要额外操作,
|
||||
// 只要 initUsers 配置正确即可。
|
||||
|
||||
// 将当前用户注入 context (使用拷贝的指针)
|
||||
u := tt.currentUser
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &u)
|
||||
l := NewBindInviteCodeLogic(ctx, svcCtx)
|
||||
|
||||
err := l.BindInviteCode(&types.BindInviteCodeRequest{InviteCode: tt.inviteCode})
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
cause := errors.Cause(err)
|
||||
codeErr, ok := cause.(*xerr.CodeError)
|
||||
if !ok {
|
||||
// handle error
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedCode, codeErr.GetErrCode())
|
||||
if tt.expectedMsg != "" {
|
||||
assert.Contains(t, codeErr.GetErrMsg(), tt.expectedMsg)
|
||||
}
|
||||
}
|
||||
if tt.expectedMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.expectedMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.name == "成功绑定邀请码" {
|
||||
assert.Equal(t, int64(2), mockModel.users[1].RefererId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,7 @@ func NewDeleteAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAccount 注销账号逻辑
|
||||
// 1. 获取当前用户信息
|
||||
// 2. 删除所有关联数据(用户、认证方式、设备)
|
||||
// 3. 根据原设备信息创建全新账号
|
||||
// 4. 返回新账号信息
|
||||
// DeleteAccount 注销当前设备账号逻辑 (改为精准解绑)
|
||||
func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) {
|
||||
// 获取当前用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
@@ -45,48 +41,74 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 获取当前调用设备 ID
|
||||
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
|
||||
|
||||
resp = &types.DeleteAccountResponse{}
|
||||
var newUserId int64
|
||||
|
||||
// 如果没有识别到设备 ID (可能是旧版 Token),则执行安全注销:仅清除 Session
|
||||
if currentDeviceId == 0 {
|
||||
l.Infow("未识别到设备 ID,仅清理当前会话", logger.Field("user_id", currentUser.Id))
|
||||
l.clearCurrentSession(currentUser.Id)
|
||||
resp.Success = true
|
||||
resp.Message = "会话已清除"
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 开始数据库事务
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
// 1. 查找用户的所有设备(用于后续创建新账号)
|
||||
devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, currentUser.Id)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "查询用户设备失败")
|
||||
// 1. 查找当前设备
|
||||
var currentDevice user.Device
|
||||
if err := tx.Where("id = ? AND user_id = ?", currentDeviceId, currentUser.Id).First(¤tDevice).Error; err != nil {
|
||||
l.Infow("当前请求设备记录不存在或归属不匹配", logger.Field("device_id", currentDeviceId), logger.Field("error", err.Error()))
|
||||
return nil // 不抛错,直接走清理 Session 流程
|
||||
}
|
||||
|
||||
// 2. 删除用户的所有认证方式
|
||||
err = tx.Model(&user.AuthMethods{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户认证方式失败")
|
||||
}
|
||||
// 2. 检查用户是否有其他认证方式 (如邮箱) 或 其他设备
|
||||
var authMethodsCount int64
|
||||
tx.Model(&user.AuthMethods{}).Where("user_id = ?", currentUser.Id).Count(&authMethodsCount)
|
||||
|
||||
// 3. 删除用户的所有设备
|
||||
err = tx.Model(&user.Device{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.Device{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户设备失败")
|
||||
}
|
||||
var devicesCount int64
|
||||
tx.Model(&user.Device{}).Where("user_id = ?", currentUser.Id).Count(&devicesCount)
|
||||
|
||||
// 4. 删除用户的订阅信息
|
||||
err = tx.Model(&user.Subscribe{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.Subscribe{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户订阅信息失败")
|
||||
}
|
||||
// 判定是否是主账号解绑:如果除了当前设备外,还有邮箱或其他设备,则只解绑当前设备
|
||||
isMainAccount := authMethodsCount > 1 || devicesCount > 1
|
||||
|
||||
// 5. 删除用户本身
|
||||
err = tx.Model(&user.User{}).Where("`id` = ?", currentUser.Id).Delete(&user.User{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户失败")
|
||||
}
|
||||
if isMainAccount {
|
||||
l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||
|
||||
// 7. 为每个原设备创建新的用户(使用同一事务)
|
||||
for _, oldDevice := range devices {
|
||||
userInfo, err := l.registerUserAndDevice(tx, oldDevice.Identifier, oldDevice.Ip, oldDevice.UserAgent)
|
||||
// 为当前设备创建新用户并迁移
|
||||
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "创建新用户失败")
|
||||
return err
|
||||
}
|
||||
newUserId = userInfo.Id // 保留最后一个新用户ID
|
||||
newUserId = newUser.Id
|
||||
|
||||
// 从原用户删除当前设备的认证方式 (按 Identifier 准确删除)
|
||||
if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除原设备认证失败")
|
||||
}
|
||||
|
||||
// 从原用户删除当前设备记录
|
||||
if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除原设备记录失败")
|
||||
}
|
||||
} else {
|
||||
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||
|
||||
// 完全删除原用户相关资产
|
||||
tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{})
|
||||
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{})
|
||||
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{})
|
||||
tx.Delete(&user.User{}, currentUser.Id)
|
||||
|
||||
// 重新注册一个新用户
|
||||
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newUserId = newUser.Id
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -96,23 +118,28 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 注销当前会话token(删除Redis中的会话标记,并从用户会话集合移除)
|
||||
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" {
|
||||
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
|
||||
// 从用户会话集合中移除当前session,避免残留
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, currentUser.Id)
|
||||
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
// 最终清理当前 Session
|
||||
l.clearCurrentSession(currentUser.Id)
|
||||
|
||||
resp.Success = true
|
||||
resp.Message = "账户注销成功"
|
||||
resp.Message = "注销成功"
|
||||
resp.UserId = newUserId
|
||||
resp.Code = 200
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// clearCurrentSession 清理当前请求的会话
|
||||
func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
|
||||
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" {
|
||||
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
|
||||
// 从用户会话集合中移除当前session
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
|
||||
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
}
|
||||
|
||||
// generateReferCode 生成推荐码
|
||||
func generateReferCode() string {
|
||||
bytes := make([]byte, 4)
|
||||
|
||||
@@ -119,6 +119,9 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
|
||||
_ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err()
|
||||
// remove session from user sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, u.Id)
|
||||
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
|
||||
l.Infow("设备解绑完成",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@@ -49,9 +50,12 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
||||
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
if payload.Code != req.Code {
|
||||
if payload.Code != req.Code { // 校验有效期(15分钟)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
|
||||
@@ -50,6 +50,11 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
||||
userId := int64(claims["UserId"].(float64))
|
||||
// get session id from token
|
||||
sessionId := claims["SessionId"].(string)
|
||||
// get device id from token
|
||||
var deviceId int64
|
||||
if claims["DeviceId"] != nil {
|
||||
deviceId = int64(claims["DeviceId"].(float64))
|
||||
}
|
||||
// get session id from redis
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
value, err := svc.Redis.Get(c, sessionIdCacheKey).Result()
|
||||
@@ -91,6 +96,9 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
||||
ctx = context.WithValue(ctx, constant.LoginType, loginType)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId)
|
||||
if deviceId > 0 {
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, deviceId)
|
||||
}
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -596,13 +596,14 @@ type EmailAuthticateConfig struct {
|
||||
}
|
||||
|
||||
type EmailLoginRequest struct {
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
Invite string `json:"invite,optional"`
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `header:"User-Agent"`
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
Invite string `json:"invite,optional"`
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `header:"User-Agent"`
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
}
|
||||
|
||||
type FilterBalanceLogRequest struct {
|
||||
|
||||
@@ -9,5 +9,6 @@ const (
|
||||
CtxKeyPlatform CtxKey = "platform"
|
||||
CtxKeyPayment CtxKey = "payment"
|
||||
LoginType CtxKey = "loginType"
|
||||
CtxKeyDeviceID CtxKey = "deviceId"
|
||||
CtxKeyIncludeExpired CtxKey = "includeExpired"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 说明文档
|
||||
|
||||
## 项目规划
|
||||
检查项目中所有邮件验证码的发送逻辑,确保过期时间统一为 15 分钟。
|
||||
|
||||
## 实施方案
|
||||
1. 搜索整个项目中涉及邮件验证码生成的代码。
|
||||
2. 搜索项目中涉及验证码存储(如 Redis)的代码。
|
||||
3. 检查过期时间常量或变量,确认是否为 900 秒或 15 分钟。
|
||||
4. 修复不符合要求的过期时间。
|
||||
5. 验证修复结果。
|
||||
|
||||
## 进度记录
|
||||
- [2026-01-12 19:58] 启动检查任务,搜索邮件逻辑。
|
||||
- [2026-01-12 20:10] 完成检查。确认以下文件的验证逻辑均为 15 分钟(900秒):
|
||||
- `internal/logic/common/sendEmailCodeLogic.go` (Redis TTL & Template)
|
||||
- `internal/logic/auth/userRegisterLogic.go` (Explicit Check)
|
||||
- `internal/logic/auth/resetPasswordLogic.go` (Explicit Check)
|
||||
- `internal/logic/auth/emailLoginLogic.go` (Explicit Check)
|
||||
- `internal/logic/public/user/bindEmailWithVerificationLogic.go` (Explicit Check)
|
||||
- `internal/logic/public/user/verifyEmailLogic.go` (Explicit Check)
|
||||
- [2026-01-12 20:11] 检查结论:所有邮件验证码发送逻辑均符合 15 分钟过期的要求。
|
||||
- [2026-01-12 20:35] **统一过期时间配置**:
|
||||
- 修改 `internal/config/config.go` 默认过期时间从 300 秒改为 900 秒
|
||||
- 修改 `initialize/migrate/database/00002_init_basic_data.up.sql` 初始值从 300 改为 900
|
||||
- 移除所有逻辑文件中的硬编码 `15` 和 `900`,改为使用 `l.svcCtx.Config.VerifyCode.ExpireTime`
|
||||
- 编译通过,无错误
|
||||
- [2026-01-13] **设备移出和邀请码优化**:
|
||||
- 修复设备B绑定邮箱后被从设备A移除时未自动退出的问题(通过踢出旧连接和清理缓存实现)
|
||||
- 优化邀请码无效时的错误提示,返回 "无邀请码"
|
||||
Reference in New Issue
Block a user