feat(handler): 添加设备WebSocket端点及测试脚本
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m15s
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:
parent
83c165458d
commit
bafeaa35cd
@ -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
239
script/test_device_login.go
Normal 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
89
script/test_ws.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user