feat(channel): 微信 iLink 账号状态三态(诚实口径)

state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:44 +08:00
1 parent f75e7a9c9b
commit 7505c46b70
8 files changed
+808 -17

No files matched your search

@@ -48,11 +48,23 @@ type weixinLoginQRResponse struct {
}
// weixinLoginStatusResponse 是 GET /admin/channels/{platform}/login-status 的线上结构。
//
// Status 是**扫码登录流程**的状态机(idle/waiting/confirmed/failed/need_verifycode);
// AuthState/AuthDetail 是**会话凭据**的三态与说明("active" / "rejected" / "unknown"),
// 两者是两件事:一次 confirmed 的扫码只说明「拿到了凭据」,凭据是否被 iLink 接受要等
// 一次成功的调用(那时 AuthState 才从 unknown 变成 active)。
//
// 🔴 errcode=-14 是「被拒」不是「过期」:AuthState=rejected 的说明只说被拒与需要重新
// 扫码,绝不写「过期」;尚未确认(unknown)同样绝不被写成「过期」,也不写成「未连接」。
// 适配器没暴露该能力(gateway 的 sessionStatusAware)时这两个键**缺席** —— 缺席是
// 「该账号没有这条状态面」,与 AuthState=unknown(有状态面但尚未确认)不是同一件事。
type weixinLoginStatusResponse struct {
Status string `json:"status"`
BotID string `json:"botId,omitempty"`
BotToken string `json:"botToken,omitempty"`
Err string `json:"err,omitempty"`
Status string `json:"status"`
BotID string `json:"botId,omitempty"`
BotToken string `json:"botToken,omitempty"`
Err string `json:"err,omitempty"`
AuthState string `json:"authState,omitempty"`
AuthDetail string `json:"authDetail,omitempty"`
}
// adminWeixinLoginQR 为 weixin-ilink 账号触发(或返回进行中的)二维码。
@@ -98,6 +110,12 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request)
}
status, detail := wx.LoginStatus(r.Context())
resp := weixinLoginStatusResponse{Status: status}
// 会话凭据三态(有效 / 被拒 / 未知)与**扫码登录流程**的状态各自独立:一次
// 扫码确认只说明拿到了凭据,凭据是否被 iLink 接受由适配器侧的证据(一次成功的
// 调用)决定。适配器没有该能力时这两个键保持缺席。
if ss, ok := s.weixinSessionStatus(accountID); ok {
resp.AuthState, resp.AuthDetail = ss.SessionStatus()
}
if status == weixin_ilink.LoginStatusConfirmed {
cfg := wx.Config()
token, _ := cfg["bot_token"].(string)
@@ -205,18 +223,20 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID
return nil
}
// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。
// 对未知账号,或未暴露登录表面的包装适配器,
// 它返回 nil。
func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) {
// weixinRawValueFor 返回为该平台/账号注册、且**满足 usable** 的已注册值本身(未拆包),
// 于是各条可选能力(扫码登录面、会话凭据三态……)可以各自对它做类型断言,而账号匹配
// 规则只写一遍。weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号),而非
// "weixin-ilink:<acct>",因此先尝试复合匹配,当调用方指定了具体账号、但没有复合名
// 承载它时,回退到裸 weixin 适配器(面板传的就是配置里的账号 id)。
//
// usable 先把「不暴露该能力的注册值」滤掉再去匹配账号:这样每条能力的匹配口径与
// 重构前各自独立查找时**逐字一致**(否则一个混装了多种适配器的注册表里,首个匹配到
// 却缺该能力的值会把后面真正可用的那个挡掉)。
func weixinRawValueFor(caps CapabilityStore, platform, accountID string, usable func(any) bool) (any, bool) {
if caps == nil {
return nil, false
}
// weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号),
// 而非 "weixin-ilink:<acct>",因此先尝试复合匹配,
// 当调用方指定了具体账号、但没有复合名承载它时,回退到
// 裸 weixin 适配器(面板传的就是配置里的账号 id)。
var bare weixinLoginAdmin
var bare any
var bareFound bool
for _, cap := range caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
@@ -227,15 +247,14 @@ func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinL
if plat != platform {
continue
}
w, ok := asWeixinLoginAdmin(cap.Value)
if !ok {
if !usable(cap.Value) {
continue
}
if accountID == "" || acct == accountID {
return w, true
return cap.Value, true
}
if acct == "" {
bare, bareFound = w, true
bare, bareFound = cap.Value, true
}
}
if bareFound {
@@ -244,6 +263,40 @@ func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinL
return nil, false
}
// weixinLoginUsable 报告该注册值是否暴露扫码登录面。
func weixinLoginUsable(v any) bool {
_, ok := asWeixinLoginAdmin(v)
return ok
}
// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。
// 对未知账号,或未暴露登录表面的包装适配器,
// 它返回 nil。
func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) {
v, ok := weixinRawValueFor(caps, platform, accountID, weixinLoginUsable)
if !ok {
return nil, false
}
return asWeixinLoginAdmin(v)
}
// weixinSessionUsable 报告该注册值是否暴露会话凭据三态。
func weixinSessionUsable(v any) bool {
_, ok := asSessionStatusAware(v)
return ok
}
// weixinSessionStatus 读取该账号适配器的**会话凭据三态**能力。适配器没暴露该能力
// (或账号未注册)时 found=false,调用方随后让两个键**缺席** —— 缺席 = 该账号没有
// 这条状态面,与 AuthState="unknown"(有状态面但尚未确认)是两件事。
func (s *Server) weixinSessionStatus(accountID string) (sessionStatusAware, bool) {
v, ok := weixinRawValueFor(s.caps, "weixin-ilink", accountID, weixinSessionUsable)
if !ok {
return nil, false
}
return asSessionStatusAware(v)
}
// asWeixinLoginAdmin 把已注册的渠道值拆到其内层 weixin-ilink
// 适配器,仿照 asKookAdmin:注册时会把适配器包进 cmd/cyrene 的
// namedAdapter(一个内嵌 adapter.PlatformAdapter 的未导出结构体);