From bafeaa35cd69711f6b9426e7a6fb8544b34a7ebb Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 22 Oct 2025 21:06:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(handler):=20=E6=B7=BB=E5=8A=A0=E8=AE=BE?= =?UTF-8?q?=E5=A4=87WebSocket=E7=AB=AF=E7=82=B9=E5=8F=8A=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增设备WebSocket通信端点/v1/app/ws/:userid/:device_number 添加测试脚本test_ws.go用于WebSocket连接测试 添加测试脚本test_device_login.go用于设备登录及绑定测试 --- internal/handler/routes.go | 7 ++ script/test_device_login.go | 239 ++++++++++++++++++++++++++++++++++++ script/test_ws.go | 89 ++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 script/test_device_login.go create mode 100644 script/test_ws.go diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 5f8d80d..da48218 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -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)) diff --git a/script/test_device_login.go b/script/test_device_login.go new file mode 100644 index 0000000..f25063b --- /dev/null +++ b/script/test_device_login.go @@ -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") + } +} diff --git a/script/test_ws.go b/script/test_ws.go new file mode 100644 index 0000000..c8e5a14 --- /dev/null +++ b/script/test_ws.go @@ -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 +}