feat(channels): QQ 账号 get_status 主动探活(复用既有 action 通道 / 状态变化才记日志 / AccountStatus 与 WS 连通分开)
This commit is contained in:
1 parent
f44114301e
commit
a79fe9684e
6 files changed
+577
-18
No files matched your search
@@ -0,0 +1,293 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件是 `get_status` 主动探活(probe.go)的端到端自证:用**本地假 OneBot 实现**
|
||||
// (WS 反连 / HTTP API)真的收一次 get_status 请求、真的回一次响应,断言
|
||||
// - 账号在线/离线被如实检测到(含 offline 判定),且 AccountStatus 可区分「未探到」;
|
||||
// - 状态**只在变化时**记一条日志(用测试 handler 捕获渲染后的日志串来数行数);
|
||||
// - 请求失败 / 超时不伪造状态,也不产生状态日志;
|
||||
// - 后台探活循环在 Disconnect 后干净退出(不再发请求)。
|
||||
//
|
||||
// 绝不用真实 QQ:全部走本机 httptest 假服务端。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeStatusWS 是本地假的 OneBot WS 实现:只认 get_status 请求,并按当前 online/good
|
||||
// 回一条带 echo 的标准响应信封。reply=false 时故意不回(模拟对端挂死 / 超时)。
|
||||
type fakeStatusWS struct {
|
||||
online atomic.Bool
|
||||
good atomic.Bool
|
||||
reply atomic.Bool
|
||||
|
||||
mu sync.Mutex
|
||||
probes int
|
||||
}
|
||||
|
||||
func (f *fakeStatusWS) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.probes
|
||||
}
|
||||
|
||||
func (f *fakeStatusWS) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgradeWS(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
for {
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Echo string `json:"echo"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Action != actionGetStatus {
|
||||
continue
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.probes++
|
||||
f.mu.Unlock()
|
||||
if !f.reply.Load() {
|
||||
continue
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": map[string]any{"online": f.online.Load(), "good": f.good.Load()},
|
||||
"echo": req.Echo,
|
||||
})
|
||||
_ = conn.WriteMessage(opText, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// countLogLines 数渲染后日志里包含 sub 的行数(用于断言「只记一次」)。
|
||||
func countLogLines(buf *bytes.Buffer, sub string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
if strings.Contains(line, sub) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestProbeDetectsAccountTransitionsOnceWS 覆盖 WS 端:真的发 get_status、真的收回执,
|
||||
// 在线→离线被检测到,且每次状态变化**只记一条**日志(good 也带上)。
|
||||
func TestProbeDetectsAccountTransitionsOnceWS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
buf, log := logCapture()
|
||||
f := &fakeStatusWS{}
|
||||
f.reply.Store(true)
|
||||
f.good.Store(true)
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
|
||||
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
|
||||
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url, Logger: log})
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||||
|
||||
ctx := context.Background()
|
||||
probe := func() {
|
||||
t.Helper()
|
||||
if err := a.probeStatus(ctx); err != nil {
|
||||
t.Fatalf("probeStatus error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
f.online.Store(true)
|
||||
probe() // 首次确立:在线 → 记一条
|
||||
probe() // 状态未变 → 不记
|
||||
|
||||
if online, known := a.AccountStatus(); !known || !online {
|
||||
t.Fatalf("AccountStatus() = (%v,%v), want (true,true) after online probe", online, known)
|
||||
}
|
||||
|
||||
f.online.Store(false)
|
||||
probe() // 在线→离线 → 记一条
|
||||
probe() // 状态未变 → 不记
|
||||
|
||||
if online, known := a.AccountStatus(); !known || online {
|
||||
t.Fatalf("AccountStatus() = (%v,%v), want (false,true) after offline probe", online, known)
|
||||
}
|
||||
|
||||
if n := countLogLines(buf, "onebot account online"); n != 1 {
|
||||
t.Errorf("「account online」日志行数 = %d, want 1\n%s", n, buf.String())
|
||||
}
|
||||
if n := countLogLines(buf, "onebot account offline"); n != 1 {
|
||||
t.Errorf("「account offline」日志行数 = %d, want 1\n%s", n, buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "good=true") {
|
||||
t.Errorf("状态日志缺少 OneBot 返回的 good\n%s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "source=plugins/channels/qq_obv11") {
|
||||
t.Errorf("状态日志缺少 source\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeOverHTTPTransport 覆盖 HTTP 端:get_status 走既有 obHTTPClient,同样能检测
|
||||
// 离线→在线;证明两条传输共用同一套探活逻辑。
|
||||
func TestProbeOverHTTPTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
buf, log := logCapture()
|
||||
var online atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": map[string]any{"online": online.Load(), "good": true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewAdapter(Config{
|
||||
Transport: transportHTTP,
|
||||
Mode: modeClient,
|
||||
APIBaseURL: srv.URL,
|
||||
Logger: log,
|
||||
})
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||||
|
||||
ctx := context.Background()
|
||||
online.Store(false)
|
||||
if err := a.probeStatus(ctx); err != nil {
|
||||
t.Fatalf("probeStatus error: %v", err)
|
||||
}
|
||||
if got, known := a.AccountStatus(); !known || got {
|
||||
t.Fatalf("AccountStatus() = (%v,%v), want (false,true)", got, known)
|
||||
}
|
||||
|
||||
online.Store(true)
|
||||
if err := a.probeStatus(ctx); err != nil {
|
||||
t.Fatalf("probeStatus error: %v", err)
|
||||
}
|
||||
if got, known := a.AccountStatus(); !known || !got {
|
||||
t.Fatalf("AccountStatus() = (%v,%v), want (true,true)", got, known)
|
||||
}
|
||||
|
||||
if n := countLogLines(buf, "onebot account offline"); n != 1 {
|
||||
t.Errorf("「account offline」日志行数 = %d, want 1\n%s", n, buf.String())
|
||||
}
|
||||
if n := countLogLines(buf, "onebot account online"); n != 1 {
|
||||
t.Errorf("「account online」日志行数 = %d, want 1\n%s", n, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeTimeoutWithoutReply 覆盖失败路径:对端收下请求却不回执 ⇒ 探活超时返回错误,
|
||||
// 账号状态保持「未探到」,绝不伪造在线/离线,也不产生状态日志。
|
||||
func TestProbeTimeoutWithoutReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
buf, log := logCapture()
|
||||
f := &fakeStatusWS{} // reply=false:只计数不回执
|
||||
f.reply.Store(false)
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
|
||||
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
|
||||
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url, Logger: log})
|
||||
a.probeTimeout = 150 * time.Millisecond
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||||
|
||||
err := a.probeStatus(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("probeStatus() = nil, want timeout error")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("probeStatus error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
if _, known := a.AccountStatus(); known {
|
||||
t.Error("AccountStatus known = true after failed probe, want false (未探到)")
|
||||
}
|
||||
if n := countLogLines(buf, "onebot account"); n != 0 {
|
||||
t.Errorf("失败探活不应产生状态日志,实得 %d 行\n%s", n, buf.String())
|
||||
}
|
||||
if f.count() == 0 {
|
||||
t.Error("假服务端从未收到 get_status 请求,探活根本没走既有通道")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeHTTPRetcodeFailure 覆盖 HTTP 端失败判定:OneBot 回 retcode!=0 时探活报错,
|
||||
// 状态不更新。
|
||||
func TestProbeHTTPRetcodeFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"status": "failed", "retcode": 100, "msg": "nope"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL})
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||||
|
||||
if err := a.probeStatus(context.Background()); err == nil {
|
||||
t.Fatal("probeStatus() = nil, want the OneBot retcode failure surfaced")
|
||||
}
|
||||
if _, known := a.AccountStatus(); known {
|
||||
t.Error("AccountStatus known = true after failed probe, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeLoopStopsOnDisconnect 覆盖后台探活循环:按 probeEvery 周期真的发请求,且
|
||||
// Disconnect 后干净退出(不再有新请求),不泄漏 goroutine。
|
||||
func TestProbeLoopStopsOnDisconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, log := logCapture()
|
||||
f := &fakeStatusWS{}
|
||||
f.reply.Store(true)
|
||||
f.online.Store(true)
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
|
||||
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
|
||||
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url, Logger: log})
|
||||
a.probeEvery = 5 * time.Millisecond
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
|
||||
if !waitUntil(3*time.Second, func() bool { return f.count() >= 2 }) {
|
||||
t.Fatalf("后台探活循环未发出请求(probes=%d)", f.count())
|
||||
}
|
||||
|
||||
if err := a.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect error: %v", err)
|
||||
}
|
||||
after := f.count()
|
||||
time.Sleep(80 * time.Millisecond) // ~16 个 probeEvery
|
||||
if final := f.count(); final != after {
|
||||
t.Errorf("Disconnect 后探活仍在继续:%d → %d", after, final)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user