feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
- 适配器新增过期状态机(plugins/channels/weixin_ilink/session_state.go):getupdates/sendmessage/getuploadurl 收到 errcode=-14 即 markExpired(ERROR 仅一次,只带 bot_id,token 永不入日志),扫码登录拿到新 token 或配置换新 token 时清除(惰性比对) - Connect/HealthCheck 在过期时返回 ErrSessionExpired(可与 ErrNotConnected 区分):不再对死 token 设 connected=true 报假成功;发送路径同样返回该错误而不是笼统 ErrNotConnected - 网关:POST /admin/channels/open 遇过期 → 409 session_expired + 明确文案(不再吞错回 200);GET /admin/channels 摘要新增 needsRescan/lastError(经可选接口类型断言读取,取不到不伪造) - channelbridge:读循环非取消退出时把自身记账的 connected 置 false(热重载被替换者退出不抹掉新实例) - login/login_admin:过期时 StartLogin 允许重新出码、LoginStatus 如实报 failed(原先可能把死 token 当 confirmed 重新落盘) - Docs/api-client v0.39;新增 5 组测试 + 变异验证(去掉标记/恢复假成功/网关不填字段 均 FAIL)
This commit is contained in:
1 parent
2092094d0a
commit
cdc370dc82
14 files changed
+975
-30
No files matched your search
@@ -0,0 +1,128 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// deadSessionAdapter mimics a channel whose session dies while the bridge is
|
||||
// running — today the weixin-iLink expired bot token (iLink errcode=-14): Connect
|
||||
// reports success, the read loop then returns on its own (nothing cancelled it)
|
||||
// and IsConnected reports false from that point on.
|
||||
type deadSessionAdapter struct {
|
||||
name string
|
||||
mu sync.Mutex
|
||||
up bool
|
||||
}
|
||||
|
||||
func (a *deadSessionAdapter) Name() string { return a.name }
|
||||
func (a *deadSessionAdapter) Config() map[string]any { return map[string]any{"bot_token": "tok"} }
|
||||
func (a *deadSessionAdapter) Connect(context.Context) error {
|
||||
a.mu.Lock()
|
||||
a.up = true
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (a *deadSessionAdapter) Disconnect(context.Context) error {
|
||||
a.mu.Lock()
|
||||
a.up = false
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (a *deadSessionAdapter) IsConnected() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.up
|
||||
}
|
||||
func (a *deadSessionAdapter) HealthCheck(context.Context) error { return adapter.ErrNotConnected }
|
||||
func (a *deadSessionAdapter) ReadMessages(context.Context, func(adapter.UnifiedMessage)) {
|
||||
// The session died: the read loop ends by itself, no cancellation involved.
|
||||
a.mu.Lock()
|
||||
a.up = false
|
||||
a.mu.Unlock()
|
||||
}
|
||||
func (a *deadSessionAdapter) SendMessage(context.Context, adapter.UnifiedResponse) error {
|
||||
return adapter.ErrNotConnected
|
||||
}
|
||||
func (a *deadSessionAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error {
|
||||
return adapter.ErrNotConnected
|
||||
}
|
||||
func (a *deadSessionAdapter) SetMessageHandler(adapter.MessageHandler) {}
|
||||
func (a *deadSessionAdapter) Capabilities() adapter.Capabilities {
|
||||
return adapter.DefaultCapabilities()
|
||||
}
|
||||
func (a *deadSessionAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// signaledAdapter is a fakeAdapter whose read loop reports when it has returned,
|
||||
// so a test can wait for a replaced adapter's exit deterministically.
|
||||
type signaledAdapter struct {
|
||||
*fakeAdapter
|
||||
exited chan struct{}
|
||||
}
|
||||
|
||||
func (a *signaledAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
|
||||
<-ctx.Done()
|
||||
close(a.exited)
|
||||
}
|
||||
|
||||
// TestReadLoopExitClearsConnectedFlag is the regression for "the adapter goes mute
|
||||
// while the bridge still claims it is connected": the read loop ends with the
|
||||
// session down and nothing cancelled it, so IsConnected/ConnectedCount must stop
|
||||
// reporting a live channel.
|
||||
func TestReadLoopExitClearsConnectedFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := newReloadBridge(t, nil)
|
||||
ad := &deadSessionAdapter{name: "weixin-ilink"}
|
||||
b.Add(ad)
|
||||
b.Start()
|
||||
|
||||
waitDeadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(waitDeadline) {
|
||||
if !b.IsConnected("weixin-ilink") {
|
||||
if n := b.ConnectedCount(); n != 0 {
|
||||
t.Fatalf("ConnectedCount() = %d, want 0 after the only read loop died", n)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("bridge still reports weixin-ilink as connected after its read loop ended")
|
||||
}
|
||||
|
||||
// TestReplacedAdapterExitKeepsReplacementConnected guards the other direction: the
|
||||
// read-loop exit accounting must not undo a replacement installed by a hot reload.
|
||||
// The replaced adapter's loop is cancelled by Reload (a deliberate stop), so its
|
||||
// exit says nothing about the adapter that now owns the name.
|
||||
func TestReplacedAdapterExitKeepsReplacementConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
old := &signaledAdapter{
|
||||
fakeAdapter: &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}},
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
factory := &stubFactory{adpts: []adapter.PlatformAdapter{
|
||||
&fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}},
|
||||
}}
|
||||
b := newReloadBridge(t, factory)
|
||||
b.Add(old)
|
||||
b.Start()
|
||||
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
<-old.exited
|
||||
// The exit accounting runs immediately after the read path returns; a short
|
||||
// settle window catches it without relying on a specific interleaving.
|
||||
settle := time.Now().Add(50 * time.Millisecond)
|
||||
for time.Now().Before(settle) {
|
||||
if !b.IsConnected("kook:conf0") {
|
||||
t.Fatal("the replaced adapter's read-loop exit cleared the replacement's connected flag")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user