Files
AskaEth 99a644d545 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」**
(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
2026-10-06 09:03:53 +08:00

219 lines
7.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// WebSocket 调参常量。ping/pong 节奏用于防止中间代理把空闲连接断开。
const (
wsEventsPattern = "ws.>"
wsPrefix = "ws."
wsChatSubject = "ws.chat"
wsStreamSubject = "ws.stream"
wsVoiceInput = "ws.voice_input"
wsVoiceOutput = "voice_output"
wsReadLimit = 4096
wsWriteWait = 10 * time.Second
wsPongWait = 60 * time.Second
wsPingPeriod = 54 * time.Second
wsSendBuffer = 32
)
// wsEvent 是下行(服务端→客户端)帧。TurnID/Seq 可选,由流式路径使用,
// 让客户端能把一串流式事件归到同一个助手回合并保持顺序。
type wsEvent struct {
Type string `json:"type"`
TurnID string `json:"turnId,omitempty"`
Seq int `json:"seq,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Data any `json:"data,omitempty"`
}
// wsInbound 是上行(客户端→服务端)帧。Data 承载按类型区分的载荷。
type wsInbound struct {
Type string `json:"type"`
ReqID string `json:"reqId,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// hub 持有连接集合并把 bus 事件转发给客户端,每个帧按其类型过门
// (见 broadcast / ws_frame_scope.go)。
type hub struct {
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
voice VoiceService
streaming bool
orchestrator *Orchestrator
history HistoryStore
// envResolver 解析每个回合的角色(design/22 §4.5);它与 server 的解析器
// 同源,使 WS 路径与 REST 路径不会分叉。
envResolver turnEnvResolver
// documents 解析回合携带的文档引用(design/24 §6.1);同样与 server 的
// 解析器同源,使 WS 帧与 REST 的文档校验口径一致。为 nil 时带附件的回合
// 被明确拒绝(见 resolveDocuments),绝不静默丢弃。
documents documentResolver
// eventStore 是渠道事件的**写入面**(design/26 §2.6;切片 E7)。为 nil 表示
// 这份构建没有留存面 ⇒ 渠道事件实时面整个不启动(见 startChannelEventWatch)。
eventStore events.Store
// accountStatus 采集各账号的在线状态快照(含未知)。它是纯函数式的取数口,
// 由 server 提供(见 channelAccountStatusSamples),hub 只负责按节拍调用。
accountStatus func() []events.StatusSample
// channelWatch 是渠道事件采样循环的句柄(未接线时为 nil)。持有它有两个理由:
// close() 之外还想让「循环到底起没起」有一个可断言的对象;测试可经
// ObserveOnce 在**确定的一拍**上驱动迁移,而不必等 60s。
channelWatch *events.Watcher
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
}
// newHub 构建一个绑定到 server 依赖的 hub。
func newHub(s *Server) *hub {
origins, allowAll := s.allowedOrigins()
return &hub{
conns: make(map[*wsConn]struct{}),
bus: s.bus,
runner: s.runner,
auditor: s.auditor,
taskOutputs: s.taskOutputs,
voice: s.voice,
streaming: s.streaming,
history: s.history,
envResolver: s.envResolver,
documents: s.resolveChatDocuments,
// 渠道事件实时面的两个来源都在构造期取定:写入面按能力断言取(未接线时为
// 真 nil ⇒ 循环不启动),状态快照直接引用 server 的方法。
eventStore: s.channelEventWriter(),
accountStatus: s.channelAccountStatusSamples,
log: s.log,
origins: origins,
allowAll: allowAll,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: originChecker(origins, allowAll),
},
}
}
// start 让 hub 订阅 bus 事件流。发往 ws.> 的事件到达此处后广播给客户端;
// 生产者桥接模式(iot/task/automation/reminder/subagent-progress)也一并订阅,
// 并归一为 ws.* 事件类型。它同时启动**渠道事件实时面**(切片 E7):那一条腿
// 只依赖写入面与状态源,没有 bus 也照常采(落行不推帧),因此不能挂在 bus 的
// 判定之后。ctx 与订阅共用:close() 取消它,两条腿一起干净退出。
func (h *hub) start() {
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
h.startChannelEventWatch(ctx)
if h.bus == nil {
return
}
patterns := append([]string{wsEventsPattern}, bridgePatterns...)
for _, p := range patterns {
if _, err := h.bus.Subscribe(ctx, p, h.relay); err != nil {
h.log.Warn("bus subscribe", "pattern", p, "err", err)
}
}
}
// close 取消 bus 订阅与渠道事件采样循环、中断所有 orchestrator 会话(使后台回合
// 不会比 hub 活得更久),并关闭每条在线连接。
func (h *hub) close() {
if h.cancel != nil {
h.cancel()
}
if h.orchestrator != nil {
h.orchestrator.InterruptAll()
}
h.mu.Lock()
conns := make([]*wsConn, 0, len(h.conns))
for c := range h.conns {
conns = append(conns, c)
}
h.conns = make(map[*wsConn]struct{})
h.mu.Unlock()
for _, c := range conns {
c.shutdown()
}
}
// register 把一条连接加入集合。
func (h *hub) register(c *wsConn) {
h.mu.Lock()
h.conns[c] = struct{}{}
h.mu.Unlock()
}
// unregister 把一条连接从集合中移除。
func (h *hub) unregister(c *wsConn) {
h.mu.Lock()
if _, ok := h.conns[c]; ok {
delete(h.conns, c)
}
h.mu.Unlock()
}
// connCount 返回在线连接数(可观测性/测试)。
func (h *hub) connCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.conns)
}
// broadcast 是下行帧的唯一投递入口,也是 design/11 §7.1 角色门本身:它按帧的
// 类型查表(frameReachesAll),把登记在册的「状态机信号」广播给每条已注册连接,
// 而其余类型 —— 内容、工具、持久化消息、图谱、角色私有状态,以及任何尚未分类
// 的类型 —— 只送达 owner + admin。连接只在 JWT 校验通过后才注册
// (ws_hub_conn.go),因此连接集合就是已认证集合。
//
// 把门放在这里而非调用点是刻意的:全包只有这一个 fan-out 循环,调用点即使忘了
// 改走受限入口,也不可能把帧投错受众。它也不会被任何单个(可能很慢的)客户端
// 阻塞:发送缓冲满即丢弃该帧。
func (h *hub) broadcast(ev wsEvent) {
b, err := json.Marshal(ev)
if err != nil {
h.log.Warn("marshal broadcast", "err", err)
return
}
adminOnly := !frameReachesAll(ev.Type)
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.conns {
if adminOnly && !ownerAdminClaims(c.claims) {
continue
}
c.enqueue(b)
}
}
// eventType 把 bus subject 映射为下行事件类型,去掉 ws. 前缀。
func eventType(subject string) string {
return strings.TrimPrefix(subject, wsPrefix)
}
// rawData 把可能为空的原始载荷转换为可安全发布的值。
func rawData(d json.RawMessage) any {
if len(d) == 0 {
return nil
}
return d
}