fix(channels): 探活失败不再静默 + 回执信封校验(杜绝把失败谎报成账号离线)+ echo 唯一化
This commit is contained in:
1 parent
8f6a75b7d7
commit
e1bd7c3009
3 files changed
+352
-10
No files matched your search
@@ -18,12 +18,23 @@ package qq_obv11
|
||||
// (复用 a.policy)退避重试,成功即回到正常间隔;单次探活超时收紧到 defaultProbeTimeout。
|
||||
// 状态**只在变化时**记一条带 source 的日志(在线→离线 / 离线→在线,含 `good`),
|
||||
// 绝不记 token / 完整 URL / 正文。ctx 取消(Disconnect)后退避循环干净退出。
|
||||
//
|
||||
// 日志契约(2026-10-05 修订):探活的四种结局都必须在日志里有一席之地,因为「没跑」与
|
||||
// 「跑了但一直失败」此前同样沉默,排障时无法区分——
|
||||
// - 启动一行:启用(间隔/超时/端)或禁用(原因),见 logProbeStart;
|
||||
// - 首次失败一行 WARN,之后每 probeWarnEvery 次再 WARN 一次、其余 DEBUG,见 logProbeFailure;
|
||||
// - 从失败恢复一行 INFO,见 runProbeLoop;
|
||||
// - 状态变化一行(INFO 在线 / WARN 离线),见 recordAccountStatus。
|
||||
// 所有失败错误的构造路径都只带 action 名与 OneBot 的 retcode/status,不带 token、完整
|
||||
// URL 与消息正文。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
@@ -40,6 +51,11 @@ const (
|
||||
defaultProbeTimeout = 5 * time.Second
|
||||
// probeEchoPrefix 让探活的 echo 与 send_msg 的 `echo_*` 区分开,避免误配。
|
||||
probeEchoPrefix = "status_"
|
||||
// probeWarnEvery 是连续失败期间「仍然打 WARN」的节流步长:第 1 次失败必 WARN,之后
|
||||
// 每 probeWarnEvery 次再 WARN 一次,其余降 DEBUG。按 reconnect.go 的退避口径
|
||||
// (2s 起步、×2、60s 封顶),10 次连续失败约 5 分钟,于是「长期掉线」既能被看见、
|
||||
// 又绝不刷屏。
|
||||
probeWarnEvery = 10
|
||||
)
|
||||
|
||||
// obStatusData 是 OneBot `get_status` 返回的 data 载荷(只取本能力需要的两个字段)。
|
||||
@@ -67,16 +83,37 @@ func (a *Adapter) AccountStatus() (online bool, known bool) {
|
||||
// probeEnabled 报告该端是否存在可用的 action 通道。WS 两端总是可能(连接稍后建立);
|
||||
// HTTP 端只有在装配了出站客户端(api_base_url)时才有得可探。
|
||||
func (a *Adapter) probeEnabled() bool {
|
||||
if a.cfg.Transport == transportHTTP {
|
||||
return a.currentAPI() != nil
|
||||
return a.probeDisabledReason() == ""
|
||||
}
|
||||
|
||||
// probeDisabledReason 返回探活不可用的原因(可用时返回空串)。它与 probeEnabled 共用
|
||||
// 同一份判据,因此日志里说的「启用/禁用」与真正是否起了循环永远一致。
|
||||
func (a *Adapter) probeDisabledReason() string {
|
||||
if a.cfg.Transport == transportHTTP && a.currentAPI() == nil {
|
||||
return "http transport without apiBaseUrl: no outbound action channel"
|
||||
}
|
||||
return true
|
||||
return ""
|
||||
}
|
||||
|
||||
// logProbeStart 在 Connect 里报一行探活状态:启用时给出间隔、超时与端,禁用时给出原因。
|
||||
// 没有这一行,「探活根本没跑」与「跑了但一直失败」在日志里长得一模一样(都是没有)。
|
||||
func (a *Adapter) logProbeStart() {
|
||||
if reason := a.probeDisabledReason(); reason != "" {
|
||||
a.log.Info("onebot get_status probe disabled", "reason", reason)
|
||||
return
|
||||
}
|
||||
a.log.Info("onebot get_status probe enabled",
|
||||
"interval", a.probeEvery, "timeout", a.probeTimeout,
|
||||
"transport", a.cfg.Transport, "mode", a.cfg.Mode)
|
||||
}
|
||||
|
||||
// runProbeLoop 是周期探活循环:正常每 probeEvery 探一次;连续失败按 reconnect 口径退避
|
||||
// (复用 a.policy.delayFor),成功即清零回到正常间隔。ctx 取消(Disconnect)后干净退出,
|
||||
// 不泄漏 goroutine。先睡眠再探活,避免与既有的启动期请求(如 HTTP 的 health probe)
|
||||
// 在同一瞬间叠成一串。
|
||||
//
|
||||
// 失败**绝不静默**:每次失败都经 logProbeFailure 落一条(首败 WARN、之后节流),恢复时
|
||||
// 补一条 INFO。此前「失败即 continue」让探活长期失败与「根本没跑」无法区分。
|
||||
func (a *Adapter) runProbeLoop(ctx context.Context) {
|
||||
failures := 0
|
||||
for {
|
||||
@@ -89,14 +126,34 @@ func (a *Adapter) runProbeLoop(ctx context.Context) {
|
||||
}
|
||||
if err := a.probeStatus(ctx); err != nil {
|
||||
failures++
|
||||
a.logProbeFailure(err, failures)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
if failures > 0 {
|
||||
a.log.Info("onebot get_status probe recovered", "failedAttempts", failures)
|
||||
failures = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logProbeFailure 记一次探活失败:连续失败里的第 1 次必 WARN(含 wrapped error、连续次数
|
||||
// 与下次重试间隔),其后每 probeWarnEvery 次再 WARN 一次,其余降 DEBUG——既守住「不许
|
||||
// 静默」,又不会在长期掉线时每秒刷屏。err 只带 action 名与 OneBot 的 retcode/status。
|
||||
func (a *Adapter) logProbeFailure(err error, failures int) {
|
||||
args := []any{"err", err, "failures", failures, "retryIn", a.policy.delayFor(failures)}
|
||||
switch {
|
||||
case failures == 1:
|
||||
a.log.Warn("onebot get_status probe failed", args...)
|
||||
case failures%probeWarnEvery == 0:
|
||||
a.log.Warn("onebot get_status probe still failing", args...)
|
||||
default:
|
||||
a.log.Debug("onebot get_status probe failed", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// probeStatus 调一次 get_status,把 online/good 落进账号状态。任何失败(未连接、对端
|
||||
// 报错、超时、data 解析失败)都以 wrapped error 返回,由退避循环决定下一次等待。
|
||||
// 报错、超时、信封/载荷不合法)都以 wrapped error 返回,由退避循环决定下一次等待,
|
||||
// 由 logProbeFailure 落日志。
|
||||
func (a *Adapter) probeStatus(ctx context.Context) error {
|
||||
pctx, cancel := context.WithTimeout(ctx, a.probeTimeout)
|
||||
defer cancel()
|
||||
@@ -104,14 +161,39 @@ func (a *Adapter) probeStatus(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq-obv11: get_status probe: %w", err)
|
||||
}
|
||||
var data obStatusData
|
||||
if err := json.Unmarshal(resp.Data, &data); err != nil {
|
||||
return fmt.Errorf("qq-obv11: decode get_status data: %w", err)
|
||||
data, err := parseStatusResponse(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.recordAccountStatus(data.Online, data.Good)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStatusResponse 校验 OneBot 响应信封并解出 online/good。
|
||||
//
|
||||
// 为什么必须在探活侧校验(而不是只管 HTTP):HTTP 端经 http_client.post,天然有
|
||||
// retcode/status 校验;WS 端是读循环把裸帧投回等待表,**没有**任何信封校验。于是
|
||||
// 「retcode!=0 且 data 为 null」的失败响应会被解成 `online=false, good=false`——
|
||||
// 把「探活失败」谎报成「账号离线」,这比不报更危险;`data` 字段缺失则退化成 json 解码
|
||||
// 错误,虽不撒谎但同样要靠日志才看得见。
|
||||
func parseStatusResponse(resp *obAPIResponse) (obStatusData, error) {
|
||||
if resp == nil {
|
||||
return obStatusData{}, errors.New("qq-obv11: get_status response: empty envelope")
|
||||
}
|
||||
if resp.RetCode != 0 || (resp.Status != "" && !strings.EqualFold(resp.Status, "ok")) {
|
||||
return obStatusData{}, fmt.Errorf("qq-obv11: get_status response: retcode=%d status=%q",
|
||||
resp.RetCode, resp.Status)
|
||||
}
|
||||
var data *obStatusData
|
||||
if err := json.Unmarshal(resp.Data, &data); err != nil {
|
||||
return obStatusData{}, fmt.Errorf("qq-obv11: decode get_status data: %w", err)
|
||||
}
|
||||
if data == nil {
|
||||
return obStatusData{}, errors.New("qq-obv11: get_status response: data is null or missing")
|
||||
}
|
||||
return *data, nil
|
||||
}
|
||||
|
||||
// recordAccountStatus 落一次账号状态观测,**只在状态发生变化时**记一条带 source 的日志
|
||||
// (在线→离线 / 离线→在线),含 OneBot 返回的 good;不记 token / 完整 URL / 正文。
|
||||
func (a *Adapter) recordAccountStatus(online, good bool) {
|
||||
@@ -155,7 +237,7 @@ func (a *Adapter) wsCall(ctx context.Context, action string, params any) (*obAPI
|
||||
ctx, cancel = context.WithTimeout(ctx, a.probeTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
echo := probeEchoPrefix + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
echo := a.nextProbeEcho()
|
||||
payload, err := buildActionReq(action, params, echo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qq-obv11: encode %s call: %w", action, err)
|
||||
@@ -173,6 +255,13 @@ func (a *Adapter) wsCall(ctx context.Context, action string, params any) (*obAPI
|
||||
}
|
||||
}
|
||||
|
||||
// nextProbeEcho 生成一个**进程内唯一**的回执关联键。此前用 time.Now().UnixNano():时钟
|
||||
// 粒度粗时相邻两次调用可能落在同一个 tick 上,后一次登记会覆盖前一次的等待通道,先登记的
|
||||
// 那个只能等到超时。改用单调计数器后,唯一性不再依赖时钟精度。
|
||||
func (a *Adapter) nextProbeEcho() string {
|
||||
return probeEchoPrefix + strconv.FormatUint(a.probeSeq.Add(1), 10)
|
||||
}
|
||||
|
||||
// registerProbeWait 登记一个待回执的 echo,返回容量 1 的投递通道(写方绝不阻塞)。
|
||||
func (a *Adapter) registerProbeWait(echo string) chan *obAPIResponse {
|
||||
ch := make(chan *obAPIResponse, 1)
|
||||
|
||||
Reference in new issue
Block a user