From efcd97e98f07622f01d53889bf081e4177d897ac Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 2 Jul 2026 20:31:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20TLS=E8=BF=9E=E6=8E=A5=E6=B1=A0=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20+=20=E7=A6=81=E8=A8=80=E6=A3=80=E6=B5=8B=20+=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E5=9B=9E=E5=A4=8D=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llm/openai.go: 移除DisableKeepAlives,启用连接池(100idle/20perhost/90s) 修复频繁TLS握手超时问题 - qq/adapter.go: 监听group_ban通知,发送前检查禁言状态 防止向被禁言群发消息导致风控 - cmd/main.go: 降级回复仅管理员私聊,群聊静默失败避免怪话 - protocol.go: OBv11Message新增Duration字段 - bridge/router.go: 处理noticeToUnified返回nil(内部消费的通知) --- backend/ai-core/cmd/main.go | 28 +++++---- backend/ai-core/internal/llm/openai.go | 7 ++- backend/platform-bridge/cmd/main.go | 13 +++++ .../internal/adapter/qq/adapter.go | 57 ++++++++++++++++++- .../internal/adapter/qq/protocol.go | 1 + .../platform-bridge/internal/bridge/router.go | 4 ++ 6 files changed, 96 insertions(+), 14 deletions(-) diff --git a/backend/ai-core/cmd/main.go b/backend/ai-core/cmd/main.go index 23d8616..7e03e21 100644 --- a/backend/ai-core/cmd/main.go +++ b/backend/ai-core/cmd/main.go @@ -1221,11 +1221,16 @@ func handleChat( IsAdmin: req.IsAdmin, }) if err != nil { - fallback := getFallbackMessage() - log.Printf("[chat] ProcessInput 失败,使用降级回复: %v", err) - fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"}) - fmt.Fprintf(w, "data: %s\n\n", fallbackData) - flusher.Flush() + // 降级回复:仅对管理员私聊发送,群聊/频道静默失败避免怪话 + if req.IsAdmin && req.Source.ChannelType == "direct" { + fallback := getFallbackMessage() + log.Printf("[chat] ProcessInput 失败,发送降级回复: %v", err) + fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"}) + fmt.Fprintf(w, "data: %s\n\n", fallbackData) + flusher.Flush() + } else { + log.Printf("[chat] ProcessInput 失败,群聊静默: %v", err) + } return } @@ -1250,11 +1255,14 @@ func handleChat( case model.StreamError: log.Printf("[chat] 流式错误: %v", event.Error) - fallback := getFallbackMessage() - log.Printf("[chat] 流式事件错误,发送降级回复: %v", event.Error) - fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"}) - fmt.Fprintf(w, "data: %s\n\n", fallbackData) - flusher.Flush() + // 降级回复:仅管理员私聊,群聊静默避免怪话 + if req.IsAdmin && req.Source.ChannelType == "direct" { + fallback := getFallbackMessage() + log.Printf("[chat] 流式错误,发送降级回复: %v", event.Error) + fallbackData, _ := json.Marshal(map[string]string{"delta": fallback, "status": "fallback"}) + fmt.Fprintf(w, "data: %s\n\n", fallbackData) + flusher.Flush() + } return case model.StreamDelta: diff --git a/backend/ai-core/internal/llm/openai.go b/backend/ai-core/internal/llm/openai.go index df291f3..ea8a967 100644 --- a/backend/ai-core/internal/llm/openai.go +++ b/backend/ai-core/internal/llm/openai.go @@ -41,9 +41,12 @@ func NewOpenAIProvider(cfg OpenAIConfig) *OpenAIProvider { cfg.Timeout = 60 * time.Second } - // 克隆默认 Transport 并关闭 keep-alive,防止 context 取消后连接池脏连接导致全阻塞 + // 使用连接池复用 TCP+TLS 连接,避免每次请求都重新 TLS 握手 tr := http.DefaultTransport.(*http.Transport).Clone() - tr.DisableKeepAlives = true + tr.MaxIdleConns = 100 + tr.MaxIdleConnsPerHost = 20 + tr.IdleConnTimeout = 90 * time.Second + tr.TLSHandshakeTimeout = 15 * time.Second return &OpenAIProvider{ config: cfg, diff --git a/backend/platform-bridge/cmd/main.go b/backend/platform-bridge/cmd/main.go index 8a44bd7..2e30506 100644 --- a/backend/platform-bridge/cmd/main.go +++ b/backend/platform-bridge/cmd/main.go @@ -442,10 +442,23 @@ func startOBv11Readers(router *bridge.PlatformRouter) { fmt.Printf("[qq:%s] route error: %v\n", adapterKey, err) continue } + // nil response = internally handled (e.g., mute tracking), skip sending. + if response == nil { + continue + } if response != nil && len(response.Messages) > 0 && !hasOnlySilentMessages(response.Messages) { messageType := msg.MessageType userID := msg.UserID groupID := msg.GroupID + // 检查群禁言状态,避免向被禁言的群发送消息导致风控 + if messageType == "group" { + if cur, err := router.GetAdapter(adapterKey); err == nil { + if qa, ok := cur.(*qqadapter.Adapter); ok && qa.IsGroupMuted(groupID) { + fmt.Printf("[qq:%s] 群 %d 处于禁言状态,跳过发送\n", adapterKey, groupID) + continue + } + } + } // Filter non-empty messages and strip 【不发送】 self-censored ones. var toSend []bridge.ResponseMessage for _, rm := range response.Messages { diff --git a/backend/platform-bridge/internal/adapter/qq/adapter.go b/backend/platform-bridge/internal/adapter/qq/adapter.go index 6d2adbd..d8fbc92 100644 --- a/backend/platform-bridge/internal/adapter/qq/adapter.go +++ b/backend/platform-bridge/internal/adapter/qq/adapter.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "regexp" + "strconv" "strings" "sync" "time" @@ -46,6 +47,9 @@ type Adapter struct { groupNames map[int64]string // group ID → group name cache groupNamesMu sync.RWMutex + mutedGroups map[int64]time.Time // group ID → mute expiry (zero = indefinitely muted) + mutedGroupsMu sync.RWMutex + pendingResponses map[string]chan *OBv11APIResponse respMu sync.Mutex } @@ -64,9 +68,42 @@ func NewAdapter(configID, configName, mode, port, accessToken, remoteURL string, sendIntervalMs: sendIntervalMs, pendingResponses: make(map[string]chan *OBv11APIResponse), groupNames: make(map[int64]string), + mutedGroups: make(map[int64]time.Time), } } +// IsGroupMuted returns true if the bot is currently muted in the given group. +func (a *Adapter) IsGroupMuted(groupID int64) bool { + a.mutedGroupsMu.RLock() + expiry, ok := a.mutedGroups[groupID] + a.mutedGroupsMu.RUnlock() + if !ok { + return false + } + if expiry.IsZero() { + return true + } + return time.Now().Before(expiry) +} + +// setGroupMuted marks the bot as muted in a group. +func (a *Adapter) setGroupMuted(groupID int64, duration int64) { + a.mutedGroupsMu.Lock() + defer a.mutedGroupsMu.Unlock() + if duration <= 0 { + a.mutedGroups[groupID] = time.Time{} + } else { + a.mutedGroups[groupID] = time.Now().Add(time.Duration(duration) * time.Second) + } +} + +// clearGroupMuted removes the mute status for a group. +func (a *Adapter) clearGroupMuted(groupID int64) { + a.mutedGroupsMu.Lock() + delete(a.mutedGroups, groupID) + a.mutedGroupsMu.Unlock() +} + // SetHTTPConfig sets the optional HTTP API configuration. func (a *Adapter) SetHTTPConfig(url, token string) { a.httpURL = url @@ -463,7 +500,23 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err } // noticeToUnified converts an OBv11 notice event (poke/戳一戳 etc.) to a UnifiedMessage. +// Returns nil when the notice is handled internally (e.g., mute tracking). func (a *Adapter) noticeToUnified(msg *OBv11Message) (*bridge.UnifiedMessage, error) { + // Handle group mute/ban notices — track mute state internally. + if msg.NoticeType == "group_ban" && msg.GroupID != 0 { + botUID, _ := strconv.ParseInt(a.selfID, 10, 64) + if msg.UserID == botUID { + if msg.SubType == "ban" { + a.setGroupMuted(msg.GroupID, msg.Duration) + log.Printf("[qq] 群 %d 被禁言 (时长=%ds)", msg.GroupID, msg.Duration) + } else if msg.SubType == "lift_ban" { + a.clearGroupMuted(msg.GroupID) + log.Printf("[qq] 群 %d 解除禁言", msg.GroupID) + } + } + return nil, nil // internally handled, no chat message to dispatch + } + senderID := fmt.Sprintf("%d", msg.UserID) senderName := senderID @@ -623,8 +676,8 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message) fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID) } - // Dispatch message and notice (poke, etc.) events. - if msg.PostType == "message" || (msg.PostType == "notice" && msg.NoticeType == "notify" && msg.SubType == "poke") { + // Dispatch message and notice events (poke, group_ban for mute tracking). + if msg.PostType == "message" || (msg.PostType == "notice" && (msg.NoticeType == "notify" || msg.NoticeType == "group_ban")) { select { case msgCh <- &msg: case <-ctx.Done(): diff --git a/backend/platform-bridge/internal/adapter/qq/protocol.go b/backend/platform-bridge/internal/adapter/qq/protocol.go index 5cd9f3d..3f525ee 100644 --- a/backend/platform-bridge/internal/adapter/qq/protocol.go +++ b/backend/platform-bridge/internal/adapter/qq/protocol.go @@ -27,6 +27,7 @@ type OBv11Message struct { // Notice fields. NoticeType string `json:"notice_type"` + Duration int64 `json:"duration"` // group_ban: mute duration in seconds (0 = indefinite) // Poke detail (sub_type === "poke"). PokeDetail *OBv11PokeDetail `json:"poke_detail,omitempty"` diff --git a/backend/platform-bridge/internal/bridge/router.go b/backend/platform-bridge/internal/bridge/router.go index f29f22f..d3e46d3 100644 --- a/backend/platform-bridge/internal/bridge/router.go +++ b/backend/platform-bridge/internal/bridge/router.go @@ -139,6 +139,10 @@ func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*U if err != nil { return nil, fmt.Errorf("convert to unified: %w", err) } + // nil means the notice was handled internally (e.g., mute tracking) — no dispatch needed. + if unified == nil { + return nil, nil + } // Preserve original platform UID before identity mapping. unified.OriginalSenderUID = unified.SenderID