package main import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "qiweimanager/config" ) const dashboardMessageLimit = 300 type DashboardMessage struct { ID int64 `json:"id"` Time string `json:"time"` ClientID int32 `json:"clientId"` Direction string `json:"direction"` Status string `json:"status"` Type string `json:"type"` Summary string `json:"summary"` Raw interface{} `json:"raw,omitempty"` RawText string `json:"rawText,omitempty"` } var ( dashboardMessageMu sync.Mutex dashboardMessageSeq int64 dashboardMessageList []DashboardMessage ) func recordDashboardMessage(clientID int32, direction string, raw string, transformed []byte, status string) { value, rawText := decodeDashboardJSON(raw) if len(transformed) > 0 { if transformedValue, transformedText := decodeDashboardJSON(string(transformed)); transformedText == "" { value = transformedValue rawText = "" } } dashboardMessageMu.Lock() dashboardMessageSeq++ msg := DashboardMessage{ ID: dashboardMessageSeq, Time: time.Now().Format("2006-01-02 15:04:05"), ClientID: clientID, Direction: direction, Status: status, Type: dashboardMessageType(value), Summary: dashboardMessageSummary(value, rawText), Raw: value, RawText: rawText, } dashboardMessageList = append(dashboardMessageList, msg) if len(dashboardMessageList) > dashboardMessageLimit { dashboardMessageList = dashboardMessageList[len(dashboardMessageList)-dashboardMessageLimit:] } dashboardMessageMu.Unlock() } func decodeDashboardJSON(raw string) (interface{}, string) { raw = strings.TrimSpace(strings.TrimRight(raw, "\x00")) if raw == "" { return nil, "" } var value interface{} if err := json.Unmarshal([]byte(raw), &value); err != nil { return nil, raw } return value, "" } func dashboardMessageType(value interface{}) string { if m, ok := value.(map[string]interface{}); ok { if typeValue, exists := m["type"]; exists { return fmt.Sprint(typeValue) } if eventValue, exists := m["event"]; exists { return fmt.Sprint(eventValue) } } return "" } func dashboardMessageSummary(value interface{}, rawText string) string { if rawText != "" { if len(rawText) > 160 { return rawText[:160] + "..." } return rawText } if m, ok := value.(map[string]interface{}); ok { if fmt.Sprint(m["type"]) == "11024" { pid := "" if data, ok := m["data"].(map[string]interface{}); ok { pid = strings.TrimSpace(fmt.Sprint(data["pid"])) } if pid != "" && pid != "" { return "连接事件(非账号登录) | pid=" + pid } return "连接事件(非账号登录)" } } fields := make([]string, 0, 8) collectDashboardFields(value, &fields) if len(fields) == 0 { if value == nil { return "" } encoded, err := json.Marshal(value) if err != nil { return fmt.Sprint(value) } text := string(encoded) if len(text) > 160 { return text[:160] + "..." } return text } summary := strings.Join(fields, " | ") if len(summary) > 220 { return summary[:220] + "..." } return summary } func collectDashboardFields(value interface{}, fields *[]string) { if len(*fields) >= 8 || value == nil { return } switch typed := value.(type) { case map[string]interface{}: keys := []string{ "type", "event", "client_id", "user_id", "conversation_id", "sender", "sender_name", "content", "message", "text", "file_name", "path", "url", } for _, key := range keys { if len(*fields) >= 8 { return } if val, exists := typed[key]; exists { text := strings.TrimSpace(fmt.Sprint(val)) if text != "" && text != "" { *fields = append(*fields, key+"="+text) } } } for _, key := range []string{"data", "msg", "messageData"} { if nested, exists := typed[key]; exists { collectDashboardFields(nested, fields) } } case []interface{}: for _, item := range typed { collectDashboardFields(item, fields) if len(*fields) >= 8 { return } } } } func getDashboardMessages(limit int) []DashboardMessage { if limit <= 0 || limit > dashboardMessageLimit { limit = 100 } dashboardMessageMu.Lock() defer dashboardMessageMu.Unlock() start := len(dashboardMessageList) - limit if start < 0 { start = 0 } result := make([]DashboardMessage, len(dashboardMessageList[start:])) copy(result, dashboardMessageList[start:]) for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { result[i], result[j] = result[j], result[i] } return result } func getDashboardMessageCount() int { dashboardMessageMu.Lock() defer dashboardMessageMu.Unlock() return len(dashboardMessageList) } func handleDashboardPage(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/dashboard" { http.NotFound(w, r) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(dashboardHTML)) } func handleDashboardState(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } clients := getUsableClientsMap() activeClientCount := usableClientCount() clientDiagnostics := getClientDiagnostics() cfg := config.GetGlobalConfig() writeDashboardJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "serverTime": time.Now().Format("2006-01-02 15:04:05"), "port": httpPort, "activeClientCount": activeClientCount, "connectionCount": connectedClientCount(), "unidentifiedCount": unidentifiedClientCount(), "clients": clients, "clientDiagnostics": clientDiagnostics, "bindableAccounts": getBindableAccountRows(), "accounts": getActiveUsersFromClientStatus(), "config": cfg, "templates": listDashboardTemplates(), "messageCount": getDashboardMessageCount(), }) } func handleDashboardMessages(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } limit := 100 if value := r.URL.Query().Get("limit"); value != "" { if parsed, err := strconv.Atoi(value); err == nil { limit = parsed } } writeDashboardJSON(w, http.StatusOK, map[string]interface{}{ "messages": getDashboardMessages(limit), }) } func handleDebugClients(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } writeDashboardJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "data": getClientDiagnostics(), }) } func handleDebugClientIdentify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } prefix := "/api/debug/clients/" suffix := strings.TrimPrefix(r.URL.Path, prefix) action := "" if strings.HasSuffix(suffix, "/identify") { action = "identify" } else if strings.HasSuffix(suffix, "/probe-account") { action = "probe-account" } else if strings.HasSuffix(suffix, "/bind") { action = "bind" } else { http.NotFound(w, r) return } clientIDText := strings.TrimSuffix(suffix, "/"+action) clientIDText = strings.Trim(clientIDText, "/") parsed, err := strconv.ParseUint(clientIDText, 10, 32) if err != nil || parsed == 0 { writeDashboardJSON(w, http.StatusBadRequest, map[string]interface{}{ "success": false, "error": "invalid clientId", }) return } if action == "probe-account" { result := runClientAccountProbe(uint32(parsed)) writeDashboardJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": result.Message, "data": result, }) return } if action == "bind" { userID := strings.TrimSpace(r.URL.Query().Get("userId")) if userID == "" { var payload map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { userID = strings.TrimSpace(fmt.Sprint(firstExisting(payload, "userId", "user_id"))) } } if userID == "" { writeDashboardJSON(w, http.StatusBadRequest, map[string]interface{}{ "success": false, "error": "missing userId", }) return } if err := bindClientToHistoricalAccount(uint32(parsed), userID); err != nil { writeDashboardJSON(w, http.StatusBadRequest, map[string]interface{}{ "success": false, "error": err.Error(), }) return } writeDashboardJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "client bound", "data": getClientDiagnostics(), }) return } retryClientIdentification(uint32(parsed)) writeDashboardJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "identify retry started", "data": getClientDiagnostics(), }) } func bindClientToHistoricalAccount(clientID uint32, userID string) error { userID = strings.TrimSpace(userID) if clientID == 0 { return fmt.Errorf("invalid clientId") } if userID == "" { return fmt.Errorf("missing userId") } for _, account := range getBindableAccountRows() { if strings.TrimSpace(fmt.Sprint(account["user_id"])) == userID { markClientManuallyBound(clientID, userID, account) globalLogger.Info("[dashboard] manually bound client %d to user_id=%s", clientID, userID) return nil } } return fmt.Errorf("userId %s not found in client_status.json", userID) } func getBindableAccountRows() []map[string]interface{} { exePath, err := os.Executable() candidates := []string{filepath.Join("config", "client_status.json")} if err == nil { candidates = append([]string{filepath.Join(filepath.Dir(exePath), "config", "client_status.json")}, candidates...) } var clientStatus map[string]interface{} for _, path := range candidates { data, readErr := os.ReadFile(path) if readErr != nil { continue } if json.Unmarshal(data, &clientStatus) == nil { break } } if len(clientStatus) == 0 { return nil } fields := []string{ "account", "acctid", "avatar", "corp_id", "corp_name", "corp_short_name", "email", "job_name", "mobile", "nickname", "position", "sex", "status", "user_id", "username", } rows := make([]map[string]interface{}, 0, len(clientStatus)) for key, value := range clientStatus { userMap, ok := value.(map[string]interface{}) if !ok { continue } userID := strings.TrimSpace(fmt.Sprint(firstExisting(userMap, "user_id", "userId"))) if userID == "" || userID == "" { userID = strings.TrimSpace(key) } if userID == "" { continue } row := make(map[string]interface{}, len(fields)) for _, field := range fields { if field == "user_id" { row[field] = userID continue } if v, exists := userMap[field]; exists { row[field] = v } } if _, exists := row["status"]; !exists { row["status"] = 1 } rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { left := fmt.Sprint(firstExisting(rows[i], "username", "nickname", "user_id")) right := fmt.Sprint(firstExisting(rows[j], "username", "nickname", "user_id")) return left < right }) return rows } func listDashboardTemplates() []string { exePath, err := os.Executable() var candidates []string if err == nil { candidates = append(candidates, filepath.Join(filepath.Dir(exePath), "requestdata")) } candidates = append(candidates, "requestdata") seen := make(map[string]bool) templates := make([]string, 0) for _, dir := range candidates { entries, err := os.ReadDir(dir) if err != nil { continue } for _, entry := range entries { if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".json" { continue } name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) if !seen[name] { seen[name] = true templates = append(templates, name) } } } sort.Strings(templates) return templates } func writeDashboardJSON(w http.ResponseWriter, statusCode int, payload interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(statusCode) _ = json.NewEncoder(w).Encode(payload) } const dashboardHTML = ` 企业微信消息管理

企业微信消息管理

等待连接

运行状态

HTTP服务
检查中
本地端口
-
可用账号
0
待识别连接
0
消息记录
0

在线企微账号 自动刷新

账号识别状态

加载中

最近消息

还没有收到消息

快捷操作

操作结果会显示在这里

调用接口模板

请选择模板并填写参数

回调配置

加载中
`