feat(handler): 添加设备WebSocket端点及测试脚本
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m15s

新增设备WebSocket通信端点/v1/app/ws/:userid/:device_number
添加测试脚本test_ws.go用于WebSocket连接测试
添加测试脚本test_device_login.go用于设备登录及绑定测试
This commit is contained in:
shanshanzhong 2025-10-22 21:06:39 -07:00
parent 83c165458d
commit bafeaa35cd
3 changed files with 335 additions and 0 deletions

View File

@ -22,6 +22,7 @@ import (
adminTicket "github.com/perfect-panel/server/internal/handler/admin/ticket"
adminTool "github.com/perfect-panel/server/internal/handler/admin/tool"
adminUser "github.com/perfect-panel/server/internal/handler/admin/user"
app "github.com/perfect-panel/server/internal/handler/app"
auth "github.com/perfect-panel/server/internal/handler/auth"
authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth"
common "github.com/perfect-panel/server/internal/handler/common"
@ -847,6 +848,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
}
// 新增App 组的 WebSocket 测试端点
appGroupRouter := router.Group("/v1/app")
{
appGroupRouter.GET("/ws/:userid/:device_number", app.DeviceWebSocketHandler(serverCtx))
}
serverGroupRouter := router.Group("/v1/server")
serverGroupRouter.Use(middleware.ServerMiddleware(serverCtx))

239
script/test_device_login.go Normal file
View File

@ -0,0 +1,239 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
)
func sha256Sum(b []byte) []byte {
sum := sha256.Sum256(b)
return sum[:]
}
func md5Hex(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
func deriveKey(secret string) []byte {
return sha256Sum([]byte(secret)) // 32 bytes for AES-256
}
func deriveIV(secret, nonce string) []byte {
ivFull := sha256Sum([]byte(md5Hex(nonce) + secret)) // 32 bytes
return ivFull[:aes.BlockSize] // 16 bytes IV
}
func pkcs7Pad(data []byte, blockSize int) []byte {
pad := blockSize - len(data)%blockSize
padding := bytes.Repeat([]byte{byte(pad)}, pad)
return append(data, padding...)
}
func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("invalid data length")
}
pad := int(data[len(data)-1])
if pad <= 0 || pad > aes.BlockSize || pad > len(data) {
return nil, errors.New("invalid padding")
}
for i := 0; i < pad; i++ {
if data[len(data)-1-i] != byte(pad) {
return nil, errors.New("invalid padding content")
}
}
return data[:len(data)-pad], nil
}
func genNonce() string {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
func encryptPayload(plain map[string]interface{}, secret string) ([]byte, string, error) {
nonce := genNonce()
key := deriveKey(secret)
iv := deriveIV(secret, nonce)
b, err := json.Marshal(plain)
if err != nil {
return nil, "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
padded := pkcs7Pad(b, aes.BlockSize)
cipherText := make([]byte, len(padded))
mode.CryptBlocks(cipherText, padded)
wrapper := map[string]string{
"data": base64.StdEncoding.EncodeToString(cipherText),
"time": nonce,
}
out, err := json.Marshal(wrapper)
return out, nonce, err
}
func decryptResponseBody(respBody []byte, secret string) (map[string]interface{}, error) {
var top map[string]interface{}
if err := json.Unmarshal(respBody, &top); err != nil {
return nil, err
}
// 响应格式可能是:
// { "code": 0, "msg": "ok", "data": { "data": "...", "time": "..." } }
// 或者直接就是 { "data": "...", "time": "..." }
var wrapper map[string]interface{}
if v, ok := top["data"].(map[string]interface{}); ok && v["data"] != nil && v["time"] != nil {
wrapper = v
} else {
wrapper = top
}
cipherB64, _ := wrapper["data"].(string)
nonce, _ := wrapper["time"].(string)
if cipherB64 == "" || nonce == "" {
return nil, errors.New("response missing data/time fields")
}
key := deriveKey(secret)
iv := deriveIV(secret, nonce)
cipherBytes, err := base64.StdEncoding.DecodeString(cipherB64)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
plainPadded := make([]byte, len(cipherBytes))
mode.CryptBlocks(plainPadded, cipherBytes)
plain, err := pkcs7Unpad(plainPadded)
if err != nil {
return nil, err
}
var out map[string]interface{}
if err := json.Unmarshal(plain, &out); err != nil {
return nil, err
}
return out, nil
}
func bindEmailWithPassword(serverURL, secret, token, email, password, userAgent string) error {
plain := map[string]interface{}{
"email": email,
"password": password,
}
body, _, err := encryptPayload(plain, secret)
if err != nil {
return err
}
req, err := http.NewRequest("POST", serverURL+"/v1/public/user/bind_email_with_password", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
req.Header.Set("Login-Type", "device")
req.Header.Set("User-Agent", userAgent)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
fmt.Println("[绑定邮箱响应]", resp.StatusCode, string(respBytes))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bind_email_with_password failed: %s", string(respBytes))
}
return nil
}
func main() {
secret := "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
serverURL := "http://localhost:8080"
identifier := "AP4A.241205.014"
userAgent := "ppanel-go-test/1.0"
plain := map[string]interface{}{
"identifier": identifier,
"user_agent": userAgent,
}
body, _, err := encryptPayload(plain, secret)
if err != nil {
fmt.Println("加密失败:", err)
os.Exit(2)
}
req, err := http.NewRequest("POST", serverURL+"/v1/auth/login/device", bytes.NewReader(body))
if err != nil {
fmt.Println("请求创建失败:", err)
os.Exit(3)
}
req.Header.Set("Login-Type", "device")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
req.Header.Set("X-Original-Forwarded-For", "127.0.0.1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("请求失败:", err)
os.Exit(4)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("读取响应失败:", err)
os.Exit(5)
}
fmt.Println("[加密响应原文]", string(respBytes))
decrypted, err := decryptResponseBody(respBytes, secret)
if err != nil {
fmt.Println("解密失败:", err)
os.Exit(6)
}
fmt.Println("[解密响应明文]", decrypted)
var token string
if t, ok := decrypted["token"].(string); ok && t != "" {
token = t
fmt.Println("✅ 登录成功token =", token)
} else {
fmt.Println("⚠️ 未获取到 token")
}
// 新增:根据邮箱密码绑定设备号(需提供 EMAIL 和 PASSWORD 环境变量)
email := "client@qq.com"
password := "123456"
if token != "" && email != "" && password != "" {
if err := bindEmailWithPassword(serverURL, secret, token, email, password, userAgent); err != nil {
fmt.Println("绑定邮箱失败:", err)
} else {
fmt.Println("✅ 绑定邮箱成功")
}
} else {
fmt.Println("跳过绑定:缺少 token、EMAIL 或 PASSWORD")
}
}

89
script/test_ws.go Normal file
View File

@ -0,0 +1,89 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"time"
"github.com/gorilla/websocket"
)
func main() {
// 默认配置,可用环境变量覆盖
baseURL := getenvDefault("SERVER_URL", "ws://127.0.0.1:8080")
userID := getenvDefault("USER_ID", "23")
deviceID := getenvDefault("DEVICE_ID", "c76463cff8512722")
auth := "dkjw6TCQTBlgreyhjN8u32gP0A6RrQ/V50vf8wjNFwFL9hgKJrOOv+ziS03GCQ/8E0fWUzjc4aCoMcVMzUN8vR7CwqR45HbtogoT9iNoElW9rgzpQNbwQ4BHK/Q25WvcgdrhfRzE19nPqUTOcN+4iY6NmeiwHEMLBTzDEeu8wGn/yjVLRMCyh5QJuQizllbrDR5LuTiNEcdSdBSx9cFZYtnJIIyi1b60BZYo4lIyRADCH6smTsLDhoZG0nJvJw3C0XCGvf0jC/4d4u40IvbzKOm1TBSK0lgOzNjvkSfS/DJibAi4l7qNTYmFlQ1wp+iW1MNllqd+OtSavZYoajoZGA=="
// 拼接完整 WS 地址
wsURL := fmt.Sprintf("%s/v1/app/ws/%s/%s", baseURL, userID, deviceID)
// 自定义 header包含 Authorization
header := http.Header{}
header.Set("Authorization", auth)
// 建立连接
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
log.Fatalf("dial error: %v", err)
}
defer conn.Close()
log.Println("connected to", wsURL)
// 优雅退出
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
// 心跳:定时发送 "ping"
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
// 读协程
done := make(chan struct{})
go func() {
defer close(done)
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
log.Printf("read error: %v", err)
return
}
log.Printf("recv [%d]: %s", mt, string(msg))
}
}()
// 连接成功后先发一个 "ping"
if err := conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
log.Printf("write ping error: %v", err)
}
for {
select {
case <-done:
return
case <-ticker.C:
if err := conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
log.Printf("write heartbeat error: %v", err)
return
}
log.Printf("sent heartbeat ping")
case <-interrupt:
log.Println("interrupt, closing...")
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
}
}
func getenvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}