refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。
- 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected
- 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留
- 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期)
- Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
This commit is contained in:
AskaEth committed 2026-10-03 18:26:27 +08:00
1 parent cdc370dc82
commit a51aadeef4
14 files changed
+205 -192

No files matched your search

@@ -11,26 +11,27 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// These tests cover the explicit session-expiry state machine (session_state.go):
// These tests cover the explicit session-rejection state machine (session_state.go):
// iLink answering errcode=-14 must be visible as "re-scan required" instead of a
// silently mute adapter or a connect that reports success with a dead token.
// silently mute adapter or a connect that reports success with a credential iLink
// already refused.
// expiringServer answers every getupdates long poll with the expired-session
// errcode, the way iLink reports a dead bot token.
func expiringServer(t *testing.T) *httptest.Server {
// rejectingServer answers every getupdates long poll with the rejected-session
// errcode, the way iLink refuses a session credential.
func rejectingServer(t *testing.T) *httptest.Server {
t.Helper()
return newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
})
}
// expireByPoll drives the adapter into the expired state through the long poll:
// rejectByPoll drives the adapter into the rejected state through the long poll:
// ReadMessages must return on errcode=-14 and the adapter must report the
// rescan-required state.
func expireByPoll(t *testing.T, ad *Adapter) {
func rejectByPoll(t *testing.T, ad *Adapter) {
t.Helper()
done := make(chan struct{})
go func() {
@@ -43,22 +44,23 @@ func expireByPoll(t *testing.T, ad *Adapter) {
}
}
// newExpiredByPollAdapter builds a connected adapter and expires it via the poll
// path.
func newExpiredByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
// newRejectedByPollAdapter builds a connected adapter and drives it into the
// rejected state via the poll path.
func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
t.Helper()
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv)
expireByPoll(t, ad)
rejectByPoll(t, ad)
return ad
}
// assertExpiredSession asserts the observable contract of an expired session: not
// connected, rescan required with a reason, HealthCheck distinguishing expired from
// not-connected, and Connect refusing with ErrSessionExpired instead of a fake OK.
func assertExpiredSession(t *testing.T, ad *Adapter) {
// assertRejectedSession asserts the observable contract of a session iLink rejected:
// not connected, rescan required with a reason, HealthCheck distinguishing the
// rejected state from not-connected, and Connect refusing with ErrSessionRejected
// instead of a fake OK.
func assertRejectedSession(t *testing.T, ad *Adapter) {
t.Helper()
if ad.IsConnected() {
t.Fatal("IsConnected() = true while the session is expired")
t.Fatal("IsConnected() = true while iLink rejects the session")
}
required, reason := ad.RescanRequired()
if !required {
@@ -69,40 +71,40 @@ func assertExpiredSession(t *testing.T, ad *Adapter) {
}
herr := ad.HealthCheck(context.Background())
if !errors.Is(herr, ErrSessionExpired) {
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired", herr)
if !errors.Is(herr, ErrSessionRejected) {
t.Fatalf("HealthCheck() = %v, want ErrSessionRejected", herr)
}
if errors.Is(herr, adapter.ErrNotConnected) {
t.Fatal("HealthCheck() also matches ErrNotConnected, want expired to be distinguishable")
t.Fatal("HealthCheck() also matches ErrNotConnected, want the rejected state to be distinguishable")
}
cerr := ad.Connect(context.Background())
if cerr == nil {
t.Fatal("Connect() = nil on an expired session, want ErrSessionExpired (no fake OK)")
t.Fatal("Connect() = nil on a rejected session, want ErrSessionRejected (no fake OK)")
}
if !errors.Is(cerr, ErrSessionExpired) {
t.Fatalf("Connect() = %v, want ErrSessionExpired", cerr)
if !errors.Is(cerr, ErrSessionRejected) {
t.Fatalf("Connect() = %v, want ErrSessionRejected", cerr)
}
if errors.Is(cerr, ErrNeedsLogin) {
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionExpired (expired is not 'unconfigured')")
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionRejected (a rejected session is not 'unconfigured')")
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a refused Connect on an expired session")
t.Fatal("IsConnected() = true after a refused Connect on a rejected session")
}
}
func TestSessionExpired_PollMarksRescanAndRefusesConnect(t *testing.T) {
func TestSessionRejected_PollMarksRescanAndRefusesConnect(t *testing.T) {
t.Parallel()
srv := expiringServer(t)
ad := newExpiredByPollAdapter(t, srv)
assertExpiredSession(t, ad)
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
assertRejectedSession(t, ad)
}
func TestSessionExpired_SendMarksRescan(t *testing.T) {
func TestSessionRejected_SendMarksRescan(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" {
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
}
return handlerResult{status: 404}
})
@@ -112,33 +114,33 @@ func TestSessionExpired_SendMarksRescan(t *testing.T) {
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, ErrSessionExpired) {
t.Fatalf("SendMessage() = %v, want ErrSessionExpired", err)
if !errors.Is(err, ErrSessionRejected) {
t.Fatalf("SendMessage() = %v, want ErrSessionRejected", err)
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a send saw errcode=-14")
}
// A later send reports the expired session itself, not a bare "not connected".
// A later send reports the rejected session itself, not a bare "not connected".
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}},
})
if !errors.Is(err, ErrSessionExpired) {
t.Fatalf("second SendMessage() = %v, want ErrSessionExpired", err)
if !errors.Is(err, ErrSessionRejected) {
t.Fatalf("second SendMessage() = %v, want ErrSessionRejected", err)
}
perr := ad.SendProactive(context.Background(), adapter.UnifiedMessage{SenderID: "u-1", Content: "push"})
if !errors.Is(perr, ErrSessionExpired) {
t.Fatalf("SendProactive() = %v, want ErrSessionExpired", perr)
if !errors.Is(perr, ErrSessionRejected) {
t.Fatalf("SendProactive() = %v, want ErrSessionRejected", perr)
}
assertExpiredSession(t, ad)
assertRejectedSession(t, ad)
}
func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/getupdates":
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-re", "qrcode_img_content": "https://img/qr",
@@ -151,20 +153,20 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
}
return handlerResult{status: 404}
})
ad := newExpiredByPollAdapter(t, srv)
assertExpiredSession(t, ad)
ad := newRejectedByPollAdapter(t, srv)
assertRejectedSession(t, ad)
// An expired token must not short-circuit the scan flow: it is not a usable
// A rejected token must not short-circuit the scan flow: it is not a usable
// bind, and ErrAlreadyLoggedIn would leave the owner no way back.
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() on an expired session = %v, want a fresh QR", err)
t.Fatalf("StartLogin() on a rejected session = %v, want a fresh QR", err)
}
if qr.Qrcode != "qr-re" {
t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode)
}
// The confirmed scan delivers a new token: the expired state must clear.
// The confirmed scan delivers a new token: the rejected state must clear.
waitFor(t, func() bool { ok, _ := ad.RescanRequired(); return !ok })
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect() after re-scan = %v, want nil", err)
@@ -182,7 +184,7 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
t.Fatalf("LoginStatus() = %q, want confirmed after a successful re-scan", status)
}
// The re-scan asked for a real rebind: the dead token is not advertised as a
// The re-scan asked for a real rebind: the rejected token is not advertised as a
// local token (that is what invites binded_redirect with no new credentials).
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })
if len(reqs) != 1 {
@@ -195,17 +197,17 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
}
}
func TestSessionExpired_ClearedByNewToken(t *testing.T) {
func TestSessionRejected_ClearedByNewToken(t *testing.T) {
t.Parallel()
srv := expiringServer(t)
ad := newExpiredByPollAdapter(t, srv)
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
// The rejected token is still configured: the session stays expired.
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("Connect() = %v, want ErrSessionExpired while the rejected token is unchanged", err)
// The rejected token is still configured: the session stays rejected.
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionRejected) {
t.Fatalf("Connect() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
}
if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired while the rejected token is unchanged", err)
if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionRejected) {
t.Fatalf("HealthCheck() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
}
setBotToken(ad, "tok-fresh")
@@ -223,12 +225,12 @@ func TestSessionExpired_ClearedByNewToken(t *testing.T) {
}
}
func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/getupdates":
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-2", "qrcode_img_content": "https://img/qr2",
@@ -241,13 +243,13 @@ func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
LoginInterval: time.Millisecond, LoginTimeout: 30 * time.Millisecond,
BotToken: "tok-old",
}, srv)
expireByPoll(t, ad)
rejectByPoll(t, ad)
// A dead token must not read as "confirmed": the panel would re-persist it and
// call the bind a success.
// A rejected credential must not read as "confirmed": the panel would re-persist
// it and call the bind a success.
status, detail := ad.LoginStatus(context.Background())
if status != LoginStatusFailed {
t.Fatalf("LoginStatus() = %q, want failed while the stored token is expired", status)
t.Fatalf("LoginStatus() = %q, want failed while iLink rejects the stored token", status)
}
if detail == "" {
t.Fatal("LoginStatus() detail is empty, want the re-scan reason")
@@ -262,7 +264,7 @@ func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
if qr.Qrcode != "qr-2" {
t.Fatalf("StartLogin() qrcode = %q, want qr-2", qr.Qrcode)
}
// While the re-scan is in progress its own state is reported, not the expired one.
// While the re-scan is in progress its own state is reported, not the rejected one.
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
t.Fatalf("LoginStatus() during the re-scan = %q, want waiting", status)
}