根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。 改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。 线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。 凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。 验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
122 lines
5.7 KiB
Go
122 lines
5.7 KiB
Go
package qq_obv11
|
||
|
||
// 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于
|
||
// 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。
|
||
//
|
||
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连
|
||
// 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能
|
||
// 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」,
|
||
// 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能
|
||
// 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事
|
||
// 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。
|
||
//
|
||
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
|
||
// transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的
|
||
// ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与
|
||
// design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出
|
||
// 「这里原本是凭据」而不是「这里本来就长这样」)。
|
||
const maskedCredential = "[redacted]"
|
||
|
||
// ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。
|
||
//
|
||
// state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending /
|
||
// ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的
|
||
// **脱敏**原因,绝不含 access_token。
|
||
//
|
||
// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的
|
||
// connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。
|
||
func (a *Adapter) ConnectOutcome() (state, reason string) {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return adapter.ConnectStateOf(adapter.ConnectSignals{
|
||
Started: a.started,
|
||
Connected: a.connected,
|
||
// 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此
|
||
// 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。
|
||
Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP,
|
||
// 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由
|
||
// Connect 直接置 connected=true,走不到这里)。
|
||
Listening: a.ln != nil,
|
||
Reason: a.lastConnectErr,
|
||
})
|
||
}
|
||
|
||
// connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。
|
||
func (a *Adapter) connectFailureReason(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return sanitizeConnectReason(err.Error(), a.cfg.AccessToken)
|
||
}
|
||
|
||
// sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可:
|
||
//
|
||
// 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的
|
||
// 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值)
|
||
// 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。
|
||
// 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token
|
||
// 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过
|
||
// 第 1 层的形状匹配,只有「已知明文」这一层能兜住。
|
||
// 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。
|
||
//
|
||
// 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪
|
||
// 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。
|
||
//
|
||
// 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。
|
||
func sanitizeConnectReason(raw, token string) string {
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
raw = trace.RedactText(raw)
|
||
if token != "" {
|
||
raw = strings.ReplaceAll(raw, token, maskedCredential)
|
||
}
|
||
return maskAccessTokenQuery(raw)
|
||
}
|
||
|
||
// maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环
|
||
// 处理多次出现(同一错误串里可能带多条 URL)。
|
||
//
|
||
// 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一
|
||
// 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。
|
||
func maskAccessTokenQuery(raw string) string {
|
||
const key = "access_token="
|
||
var b strings.Builder
|
||
rest := raw
|
||
for {
|
||
i := strings.Index(strings.ToLower(rest), key)
|
||
if i < 0 {
|
||
b.WriteString(rest)
|
||
return b.String()
|
||
}
|
||
start := i + len(key)
|
||
end := start
|
||
for end < len(rest) && !isQueryValueDelimiter(rest[end]) {
|
||
end++
|
||
}
|
||
b.WriteString(rest[:start])
|
||
b.WriteString(maskedCredential)
|
||
rest = rest[end:]
|
||
}
|
||
}
|
||
|
||
// isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符,
|
||
// 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。
|
||
func isQueryValueDelimiter(c byte) bool {
|
||
switch c {
|
||
case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|