Compare commits

...

3 Commits

Author SHA1 Message Date
01ccd44e84 feat: optimize docker-compose, add observability stack (Grafana/Loki/Prometheus), and host nginx config
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m10s
2026-01-13 18:17:12 -08:00
e5e60f73c2 Debug: Add logs to trace Telegram initialization 2026-01-13 07:00:11 -08:00
14489b6afd Update: Save current progress 2026-01-13 06:56:26 -08:00
30 changed files with 1435 additions and 9 deletions

View File

@ -46,6 +46,8 @@ type (
RefererId int64 `json:"referer_id"` RefererId int64 `json:"referer_id"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
MemberStatus string `json:"member_status"`
Remark string `json:"remark"`
} }
UpdateUserNotifySettingRequest { UpdateUserNotifySettingRequest {
UserId int64 `json:"user_id" validate:"required"` UserId int64 `json:"user_id" validate:"required"`

91
configs/ppanel.yaml Normal file
View File

@ -0,0 +1,91 @@
# PPanel Server Configuration
# 完整配置示例
# 运行模式: debug, release, test
Model: release
# 监听地址
Host: 0.0.0.0
# 监听端口
Port: 8080
# 是否开启调试模式
Debug: false
# JWT 认证配置
JwtAuth:
AccessSecret: "ppanel-secret-key-change-me" # 请务必修改此密钥
AccessExpire: 604800 # Token 过期时间 (秒), 默认 7 天
MaxSessionsPerUser: 3 # 每个用户最大并发登录数
# 日志配置
Logger:
ServiceName: "PPanel"
Mode: "file" # console, file, volume
Encoding: "json" # json, plain
Path: "logs" # 日志文件路径
Level: "info" # debug, info, warn, error
Compress: true
KeepDays: 7
Rotation: "daily" # daily, size
# MySQL 数据库配置
MySQL:
Addr: "mysql:3306" # Docker 服务名:端口
Username: "root"
Password: "ppanel_password" # 与 docker-compose 中的 MYSQL_ROOT_PASSWORD 保持一致
Dbname: "ppanel_db"
Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"
MaxIdleConns: 10
MaxOpenConns: 100
SlowThreshold: 1000 # 慢查询阈值 (ms)
# Redis 配置
Redis:
Host: "redis:6379" # Docker 服务名:端口
Pass: ""
DB: 0
# 管理员初始化配置 (仅在首次初始化有效,后续请在数据库管理)
Administrator:
Email: "admin@ppanel.dev"
Password: "password"
# 站点配置
Site:
Title: "PPanel"
Dec: "PPanel Panel"
Url: "https://your-domain.com"
SubUrl: "https://sub.your-domain.com"
# 邮件服务配置
Email:
Enable: false
# platform: "smtp"
# platform_config: "..."
# 验证配置
Verify:
TurnstileSiteKey: ""
TurnstileSecret: ""
LoginVerify: false
RegisterVerify: false
ResetPasswordVerify: false
# 注册配置
Register:
StopRegister: false
EnableTrial: false
TrialSubscribe: 0
TrialTime: 0
TrialTimeUnit: "hour"
EnableIpRegisterLimit: false
IpRegisterLimit: 0
IpRegisterLimitDuration: 0
# 订阅配置
Subscribe:
SingleModel: false
SubscribePath: "/v1/subscribe/config"
SubscribeDomain: ""
PanDomain: false
UserAgentLimit: false
UserAgentList: ""

309
docker-compose.cloud.yml Normal file
View File

@ -0,0 +1,309 @@
# PPanel 服务部署 (云端/无源码版)
# 使用方法:
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/ 目录上传到服务器同一目录
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件
# 3. 确保 logs/ 目录存在 (mkdir logs)
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
services:
# ----------------------------------------------------
# 1. 业务后端 (PPanel Server)
# ----------------------------------------------------
ppanel-server:
image: registry.kxsw.us/ario-server:latest
container_name: ppanel-server
restart: always
ports:
- "8080:8080" # 暴露端口供宿主机 Nginx 反代
volumes:
# 挂载配置文件和日志
- ./configs:/app/etc
- ./logs:/app/logs
environment:
- TZ=Asia/Shanghai
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- mysql
- redis
# ----------------------------------------------------
# 2. MySQL Database
# ----------------------------------------------------
mysql:
image: mysql:8.0
container_name: ppanel-mysql
restart: always
ports:
- "3306:3306" # 临时开放外部访问,用完记得关闭!
environment:
MYSQL_ROOT_PASSWORD: "ppanel_password" # 请修改为强密码
MYSQL_DATABASE: "ppanel_db"
TZ: Asia/Shanghai
command: --default-authentication-plugin=mysql_native_password
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d # 初始化脚本
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 3. Redis
# ----------------------------------------------------
redis:
image: redis:7.0
container_name: ppanel-redis
restart: always
volumes:
- redis_data:/data
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 4. Loki (日志存储)
# ----------------------------------------------------
loki:
image: grafana/loki:3.0.0
container_name: ppanel-loki
restart: always
volumes:
# 必须上传 loki 目录到服务器
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 5. Promtail (日志采集)
# ----------------------------------------------------
promtail:
image: grafana/promtail:3.0.0
container_name: ppanel-promtail
restart: always
volumes:
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock
# 采集当前目录下的 logs 文件夹
- ./logs:/var/log/ppanel-server:ro
command: -config.file=/etc/promtail/config.yaml
networks:
- ppanel_net
depends_on:
- loki
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 6. Grafana (日志界面)
# ----------------------------------------------------
grafana:
image: grafana/grafana:latest
container_name: ppanel-grafana
restart: always
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
# 自动加载数据源和仪表盘配置
- ./grafana/provisioning:/etc/grafana/provisioning
# 挂载本地仪表盘 JSON 文件目录
- ./grafana/dashboards:/var/lib/grafana/dashboards
networks:
- ppanel_net
depends_on:
- loki
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 7. Prometheus (指标采集)
# ----------------------------------------------------
prometheus:
image: prom/prometheus:latest
container_name: ppanel-prometheus
restart: always
ports:
- "9090:9090" # 暴露端口便于调试
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 8. Redis Exporter (Redis指标导出)
# ----------------------------------------------------
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: ppanel-redis-exporter
restart: always
environment:
- REDIS_ADDR=redis://redis:6379
networks:
- ppanel_net
depends_on:
- redis
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 9. Nginx Exporter (监控宿主机 Nginx)
# ----------------------------------------------------
nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest
container_name: ppanel-nginx-exporter
restart: always
# 使用 host.docker.internal 访问宿主机
command:
- -nginx.scrape-uri=http://host.docker.internal:80/nginx_status
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 10. MySQL Exporter (MySQL指标导出)
# ----------------------------------------------------
mysql-exporter:
image: prom/mysqld-exporter:latest
container_name: ppanel-mysql-exporter
restart: always
command:
- --config.my-cnf=/etc/.my.cnf
volumes:
- ./mysql/.my.cnf:/etc/.my.cnf:ro
networks:
- ppanel_net
depends_on:
- mysql
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 11. Jaeger (链路追踪)
# ----------------------------------------------------
jaeger:
image: jaegertracing/all-in-one:latest
container_name: ppanel-jaeger
restart: always
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
- LOG_LEVEL=debug
- COLLECTOR_OTLP_ENABLED=true
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 12. Node Exporter (宿主机监控)
# ----------------------------------------------------
node-exporter:
image: prom/node-exporter:latest
container_name: ppanel-node-exporter
restart: always
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 13. cAdvisor (容器监控)
# ----------------------------------------------------
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: ppanel-cadvisor
restart: always
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks:
- ppanel_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
mysql_data:
redis_data:
loki_data:
grafana_data:
prometheus_data:
networks:
ppanel_net:
name: ppanel_net
driver: bridge

View File

@ -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 错误码和 "无邀请码" 提示。
---
## 遗留问题 / 注意事项
- 无
## 结论
修复已完成,符合预期。

View File

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

View File

@ -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是否被踢下线
- 输入无效邀请码 → 检查错误提示

View File

@ -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. **代码风格一致**:遵循项目现有的错误处理模式

View File

@ -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` 的内存使用情况,确保大量的踢出操作不会造成锁竞争。

View File

@ -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 最后执行

View File

@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: 'Default'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
options:
path: /var/lib/grafana/dashboards

View File

@ -0,0 +1,27 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
orgId: 1
url: http://prometheus:9090
isDefault: true
version: 1
editable: true
- name: Loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
version: 1
editable: true
- name: Jaeger
type: jaeger
access: proxy
orgId: 1
url: http://jaeger:16686
version: 1
editable: true

View File

@ -116,7 +116,7 @@ VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-2
'2025-04-22 14:25:16.641'), '2025-04-22 14:25:16.641'),
(37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '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'), '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'), '2025-04-22 14:25:16.641'),
(39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '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'), '2025-04-22 14:25:16.641'),

View File

@ -16,6 +16,7 @@ import (
func Telegram(svc *svc.ServiceContext) { func Telegram(svc *svc.ServiceContext) {
logger.Infof("Telegram Config Enable: %v", svc.Config.Telegram.Enable) logger.Infof("Telegram Config Enable: %v", svc.Config.Telegram.Enable)
if !svc.Config.Telegram.Enable { if !svc.Config.Telegram.Enable {
logger.Info("Telegram disabled, skipping initialization")
return return
} }
@ -45,6 +46,7 @@ func Telegram(svc *svc.ServiceContext) {
return return
} }
logger.Info("Initializing Telegram Bot API...")
bot, err := tgbotapi.NewBotAPI(usedToken) bot, err := tgbotapi.NewBotAPI(usedToken)
if err != nil { if err != nil {
logger.Error("[Init Telegram Config] New Bot API Error: ", logger.Field("error", err.Error())) logger.Error("[Init Telegram Config] New Bot API Error: ", logger.Field("error", err.Error()))

View File

@ -226,7 +226,7 @@ type TLS struct {
} }
type VerifyCode struct { type VerifyCode struct {
ExpireTime int64 `yaml:"ExpireTime" default:"300"` ExpireTime int64 `yaml:"ExpireTime" default:"900"`
Limit int64 `yaml:"Limit" default:"15"` Limit int64 `yaml:"Limit" default:"15"`
Interval int64 `yaml:"Interval" default:"60"` Interval int64 `yaml:"Interval" default:"60"`
} }

View File

@ -121,6 +121,8 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
userInfo.Commission = req.Commission userInfo.Commission = req.Commission
} }
tool.DeepCopy(userInfo, req) tool.DeepCopy(userInfo, req)
userInfo.Remark = req.Remark
userInfo.MemberStatus = req.MemberStatus
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase
userInfo.ReferralPercentage = req.ReferralPercentage userInfo.ReferralPercentage = req.ReferralPercentage

View File

@ -73,7 +73,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
if err := json.Unmarshal([]byte(value), &payload); err != nil { if err := json.Unmarshal([]byte(value), &payload); err != nil {
continue continue
} }
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= 900 { if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
verified = true verified = true
cacheKeyUsed = cacheKey cacheKeyUsed = cacheKey
break break

View File

@ -84,7 +84,7 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
} }
// 校验有效期15分钟 // 校验有效期15分钟
if time.Now().Unix()-payload.LastAt > 900 { 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)) 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") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
} }

View File

@ -78,7 +78,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
} }
// 校验有效期15分钟 // 校验有效期15分钟
if time.Now().Unix()-payload.LastAt > 900 { if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
} }
l.svcCtx.Redis.Del(l.ctx, cacheKey) l.svcCtx.Redis.Del(l.ctx, cacheKey)

View File

@ -91,7 +91,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
"Type": req.Type, "Type": req.Type,
"SiteLogo": l.svcCtx.Config.Site.SiteLogo, "SiteLogo": l.svcCtx.Config.Site.SiteLogo,
"SiteName": l.svcCtx.Config.Site.SiteName, "SiteName": l.svcCtx.Config.Site.SiteName,
"Expire": 15, "Expire": l.svcCtx.Config.VerifyCode.ExpireTime / 60,
"Code": code, "Code": code,
} }
// Save to Redis // Save to Redis
@ -101,7 +101,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
} }
// Marshal the payload // Marshal the payload
val, _ := json.Marshal(payload) val, _ := json.Marshal(payload)
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Minute*15).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)) 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") return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
} }

View File

@ -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 不存在,说明已移除,符合预期
}
}

View File

@ -67,7 +67,7 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
continue continue
} }
// 校验验证码及有效期15分钟 // 校验验证码及有效期15分钟
if p.Code == req.Code && time.Now().Unix()-p.LastAt <= 900 { if p.Code == req.Code && time.Now().Unix()-p.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err() _ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
verified = true verified = true
break break
@ -141,6 +141,8 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
l.Errorw("查询用户设备列表失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId)) l.Errorw("查询用户设备列表失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "查询用户设备列表失败") return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "查询用户设备列表失败")
} }
// 保存原用户ID用于踢出旧连接
originalUserId := u.Id
for _, device := range devices { for _, device := range devices {
// 删除原本的设备记录 // 删除原本的设备记录
// err = l.svcCtx.UserModel.DeleteDevice(l.ctx, device.Id) // err = l.svcCtx.UserModel.DeleteDevice(l.ctx, device.Id)
@ -155,6 +157,27 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
l.Errorw("更新邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId)) l.Errorw("更新邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "更新原本的设备记录失败") 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 因为之前 默认 设备登录的时候 创建了一个设备认证数据 // 再次更新 user_auth_method 因为之前 默认 设备登录的时候 创建了一个设备认证数据
// 现在需要 更新 为 邮箱认证 // 现在需要 更新 为 邮箱认证

View File

@ -10,6 +10,7 @@ import (
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
) )
type BindInviteCodeLogic struct { 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) referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
if err != nil { 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) logger.WithContext(l.ctx).Error(err)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error()) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error())
} }

View File

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

View File

@ -119,6 +119,9 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
_ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err() _ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err()
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err() _ = 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.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
l.Infow("设备解绑完成", l.Infow("设备解绑完成",

View File

@ -53,7 +53,7 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
if payload.Code != req.Code { // 校验有效期15分钟 if payload.Code != req.Code { // 校验有效期15分钟
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
} }
if time.Now().Unix()-payload.LastAt > 900 { if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired") return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
} }
l.svcCtx.Redis.Del(l.ctx, cacheKey) l.svcCtx.Redis.Del(l.ctx, cacheKey)

View File

@ -24,6 +24,8 @@ type User struct {
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"` EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"`
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"` EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
LastLoginTime *time.Time `gorm:"comment:Last Login Time"` LastLoginTime *time.Time `gorm:"comment:Last Login Time"`
MemberStatus string `gorm:"type:varchar(20);default:'';comment:Member Status"` // Member Status
Remark string `gorm:"type:varchar(255);default:'';comment:Remark"` // Remark
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"`
UserDevices []Device `gorm:"foreignKey:UserId;references:Id"` UserDevices []Device `gorm:"foreignKey:UserId;references:Id"`
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`

View File

@ -2548,6 +2548,8 @@ type UpdateUserBasiceInfoRequest struct {
RefererId int64 `json:"referer_id"` RefererId int64 `json:"referer_id"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
MemberStatus string `json:"member_status"`
Remark string `json:"remark"`
} }
type UpdateUserNotifyRequest struct { type UpdateUserNotifyRequest struct {

37
nginx_host_config.conf Normal file
View File

@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost; # 请修改为您实际的域名,如 ppanel.example.com
# ----------------------------------------------------
# 1. Nginx Status (探针/监控端点)
# ----------------------------------------------------
# 用于 nginx-exporter 采集 Nginx 自身指标 (连接数、请求数等)
location /nginx_status {
stub_status on;
access_log off;
# 允许 Docker 容器和本地访问
allow 127.0.0.1;
allow 172.16.0.0/12; # Docker bridge 默认网段
allow 192.168.0.0/16; # Docker Desktop for Mac/Windows 网段
allow 10.0.0.0/8; # 其他常见私有网段
deny all;
}
# ----------------------------------------------------
# 2. PPanel Server (后端 API)
# ----------------------------------------------------
# 将请求转发到宿主机 8080 端口 (docker-compose 中暴露的端口)
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持 (如果需要)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

46
prometheus/prometheus.yml Normal file
View File

@ -0,0 +1,46 @@
# Prometheus 配置文件
global:
scrape_interval: 15s # 默认抓取频率
evaluation_interval: 15s
scrape_configs:
# ----------------------------------------
# 1. Prometheus 自身监控
# ----------------------------------------
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
# ----------------------------------------
# 2. 业务后端 (PPanel Server)
# ----------------------------------------
# 如果您的后端代码暴露了 /metrics 接口,可启用此项
# - job_name: "ppanel-server"
# static_configs:
# - targets: ["ppanel-server:8080"]
# ----------------------------------------
# 3. 基础设施监控 (Redis, MySQL, Nginx)
# ----------------------------------------
- job_name: "redis"
static_configs:
- targets: ["redis-exporter:9121"]
- job_name: "mysql"
static_configs:
- targets: ["mysql-exporter:9104"]
- job_name: "nginx"
static_configs:
- targets: ["nginx-exporter:9113"]
# ----------------------------------------
# 4. 宿主机与容器资源监控
# ----------------------------------------
- job_name: "node-exporter" # 宿主机硬件资源
static_configs:
- targets: ["node-exporter:9100"]
- job_name: "cadvisor" # 容器资源
static_configs:
- targets: ["cadvisor:8080"]

30
说明文档.md Normal file
View File

@ -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移除时未自动退出的问题通过踢出旧连接和清理缓存实现
- 优化邀请码无效时的错误提示,返回 "无邀请码"