spi

package
v0.12.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

spi(契约层)

核心与后端之间的一纸契约:Store / Sink / Queue 三分法 + 默认装配

这个域干什么

spi 定义核心库与所有后端之间的接口边界:核心域只认这里的契约,适配器仓库 (go-wsc-redis-adapter 等)只实现这里的契约两边可以独立演进

v4 起本域吸收原 wiring/ 包(裁决记录 #12):装配(Initialize 系列)是契约层的 默认组装实现——契约与默认装配放一起,不再单独立包

核心概念

命名三分法(消灭 Repository 等四种叫法,REFACTORING_DESIGN §7.3)
后缀 语义 契约
*Store 读写热点状态,失败可降级 OnlineStore GroupStore WorkloadStore ConnectionStore ConnectionQualityStore HubStats
*Sink 只写不读,记录 / 归档 MessageSink ArchiveSink
*Queue 进出队 + ack 语义 OfflineQueue MessageQueue
其余契约
契约 说明
ConnectionAuthenticator 认证:Authenticate(r) (*ConnectionClaims, error);默认实现 AES-GCM 对称加解密,内置 transport 域(transport.NewTokenAuthenticator),第三方鉴权实现本契约后注入
Logger 日志契约(KV 风格,hub 内默认实现)
StoreDeps / StoreHooks / StoreTarget wiring 装配的注入结构(原 wiring/ 并入)
一等后端 → 契约映射(§7.1)
一等后端 承担契约
Redis OnlineStore(bitmap)、路由缓存、OfflineQueue、分布式锁
NATS MessageQueue(JetStream)、跨节点兜底
ClickHouse ArchiveSink(批量归档)

怎么用

package main

import (
    "github.com/kamalyes/go-wsc/hub"
    "github.com/kamalyes/go-wsc/spi"
)

// 实现契约(通常直接用适配器仓库)
var _ spi.OnlineStore = redisAdapter

// 注入:显式选择,不接即零成本
h := hub.NewHub(cfg,
    hub.WithOnlineStore(redisAdapter),
    hub.WithMessageSink(gormAdapter),
    hub.WithArchiveSink(clickhouseAdapter),
)

// 默认装配(原 wiring):零注入时各域拿到的就是内存 no-op 实现
spi.Initialize(h, spi.StoreDeps{...})

端口与依赖

  • spi 是依赖链的底:不 import 任何域包,零存储驱动依赖
  • 所有契约方法第一参数 ctx context.Context(全链路 trace 传播)

性能与陷阱

  • 契约方法在热路径上(如 OnlineStore.IsOnline)签名要瘦身:只传 key,不传结构体
  • 陷阱:契约里出现 Repository 后缀即违规(grep -rnE "Repository(Impl)?\b" spi/ 为空)
  • 陷阱:给契约加方法要先问「是否两个以上后端都用」——单后端特有逻辑放适配器

后端绑定

本层零绑定:只定义契约一等后端实现矩阵见 §7.1 / §7.4

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrOfflineStorageSkipped = errors.New("offline storage skipped for message type")

ErrOfflineStorageSkipped 表示消息类型被策略排除,不能将跳过视为保存成功

Functions

func FlushDefaultLogger

func FlushDefaultLogger() error

FlushDefaultLogger 刷出默认日志器的缓冲

默认日志器未被构造过时为 no-op(不触发 Once,避免为了刷缓冲反而建出 writer 与其 flusher goroutine)。可重复调用。 关停收口见 hub.SafeShutdown;panic 路径不依赖本函数——ConsoleWriter 队列满时 自行降级为持锁同步直写(writeSync),故进程崩溃前的 panic 日志不经缓冲丢失。

func Initialize

func Initialize(
	ctx context.Context,
	hub StoreTarget,
	redisClient redis.UniversalClient,
	db *gorm.DB,
	hooks ...StoreHooks,
) error

Initialize 装配全部存储后端

参数:

  • ctx: 装配超时上下文(用于 Redis Ping 连通性校验)
  • hub: 注入目标(StoreTarget 能力面)
  • redisClient / db: 基础设施句柄,由调用方持有,本函数不接管其生命周期
  • hooks: 各适配器提供的装配钩子,可为 nil(表示该后端未部署)

返回:

  • error: 前置校验失败或任一 hook 装配失败

Types

type ConnectionAuthenticator

type ConnectionAuthenticator interface {
	// Authenticate 从 HTTP 请求中提取并解密 token
	// 返回解密后的连接身份声明;token 不存在/无效/篡改/过期时返回 error
	Authenticate(r *http.Request) (*ConnectionClaims, error)
}

ConnectionAuthenticator 连接鉴权器 SPI 从 HTTP 握手请求中提取并解密连接凭证,返回连接身份声明 内置实现见 transport 包 TokenAuthenticator(AES-GCM 对称加解密); 业务侧可实现本接口替换默认鉴权(如 OAuth/私有 Token),经 Hub 注入

type ConnectionClaims

type ConnectionClaims struct {
	UserID    string `json:"uid"`           // 用户ID(必填)
	UserType  string `json:"utp,omitempty"` // 用户类型(默认 visitor)
	DeviceID  string `json:"did,omitempty"` // 设备ID
	AppID     string `json:"aid,omitempty"` // 应用ID(最上层隔离维度,默认 "__default_app__")
	Namespace string `json:"tid,omitempty"` // 命名空间ID(默认 "default",用于命名空间隔离与消息过滤)
	GroupID   string `json:"gid,omitempty"` // 群组ID(可选,支持逗号分隔多群组"g1,g2,g3";用于群组消息过滤与连接时自动加入)
	ExpiresAt int64  `json:"exp,omitempty"` // 过期时间(Unix 秒;签发时自动填充,解密后校验)
}

ConnectionClaims 连接 Token 的身份声明(加密载荷) 将原本明文暴露的 user_id/user_type/device_id/app_id/namespace/group_id 加密到 token 中 字段名采用短缩写以减小 token 体积 注意:

  • app_id 为应用ID(最上层隔离维度,默认 "__default_app__")
  • group_id 为连接时自动加入的成员组标识(支持逗号分隔多群组"g1,g2,g3";空则加入默认组)
  • 群组成员关系的后续变更仍由业务层 API 管理

type ConnectionQualityStore

type ConnectionQualityStore interface {
	// Upsert 创建或更新质量记录(首次连接建初始零值行 QualityScore=100,重连 reconnect_count+1)
	Upsert(ctx context.Context, quality *models.ConnectionQuality) error

	// BatchUpdateHeartbeats 批量更新 Ping 统计与活跃时间(单事务,心跳时间戳由 ConnectionStore 写 connect 表)
	BatchUpdateHeartbeats(ctx context.Context, entries []*models.HeartbeatUpdateEntry) error

	// BatchIncrementStats 批量递增消息/字节统计(单事务)
	BatchIncrementStats(ctx context.Context, entries []*models.StatsIncrementEntry) error

	// BatchAddErrors 批量记录连接错误(单事务)
	// 同一连接的多次错误由调用方在攒批 flush 时合并为一条 ErrorUpdateEntry(count 累加、保留最新错误)
	BatchAddErrors(ctx context.Context, entries []*models.ErrorUpdateEntry) error

	// BatchFinalizeOnDisconnect 批量断开终评(唯一入口,单条 FinalizeOnDisconnect 已废弃)
	// 停机路径逐连接终评 = 每连接 3 条 SQL(SELECT quality + SELECT duration + UPDATE score),
	// 26w 连接 = 78w 条 SQL 在停机窗口直打 DB,是滚动更新期 DB 过载的主源;
	// 批量化后 3 SQL/连接 → 每块 1 条 IN 读 + 1 条 CASE WHEN 写(Go 内算分),
	// duration 由调用方从 MarkDisconnectedBatch 返回的 entries 带入(与 SQL 写入值同源,无需读回)
	BatchFinalizeOnDisconnect(ctx context.Context, entries []*models.DisconnectionEntry) error

	// GetByConnectionID 根据连接ID获取质量记录
	GetByConnectionID(ctx context.Context, connectionID string) (*models.ConnectionQuality, error)

	// GetByUserID 根据用户ID获取所有质量记录
	GetByUserID(ctx context.Context, userID string) ([]*models.ConnectionQuality, error)

	// GetHighErrorRateConnections 获取高错误率连接
	GetHighErrorRateConnections(ctx context.Context, errorThreshold int, limit int) ([]*models.ConnectionQuality, error)

	// GetFrequentReconnectConnections 获取频繁重连的连接
	GetFrequentReconnectConnections(ctx context.Context, reconnectThreshold int, limit int) ([]*models.ConnectionQuality, error)

	// Close 关闭仓库
	Close() error
}

ConnectionQualityStore 连接质量存储接口 承载运行时质量指标(心跳/消息/错误/重连)与评分,随 batcher 高频批量更新

type ConnectionStore

type ConnectionStore interface {

	// Upsert 创建或更新连接记录(首次连接创建,重连时更新)
	Upsert(ctx context.Context, record *models.ConnectionRecord) error

	// BatchMarkDisconnected 批量标记断连终态(CASE WHEN 单 SQL 合并)
	// 断连终态唯一写入口:正常注销经 DisconnectionBatcher 攒批 flush,停机路径分块直调;
	// 写 duration/disconnected_at/is_abnormal 供质量终评(FinalizeOnDisconnect)读,
	// connectedAt 由调用方从内存 Client 快照带入(省前置 SELECT);
	// IN 影响 0 行(记录已被清理)静默返回;批内同连接去重保留最后(终态以最后一次为准)
	BatchMarkDisconnected(ctx context.Context, entries []*models.DisconnectionEntry) error

	// BatchUpdateHeartbeats 批量更新心跳时间戳(connect 表 last_ping_at,单 SQL)
	BatchUpdateHeartbeats(ctx context.Context, entries []*models.HeartbeatUpdateEntry) error

	// GetByConnectionID 根据连接ID获取连接记录
	GetByConnectionID(ctx context.Context, connectionID string) (*models.ConnectionRecord, error)

	// GetByUserID 根据用户ID获取所有连接记录(支持多设备)
	GetByUserID(ctx context.Context, userID string) ([]*models.ConnectionRecord, error)

	// GetActiveByUserID 根据用户ID获取所有活跃连接记录
	GetActiveByUserID(ctx context.Context, userID string) ([]*models.ConnectionRecord, error)

	// List 通用列表查询(支持条件过滤)
	List(ctx context.Context, opts *models.ConnectionQueryOptions) ([]*models.ConnectionRecord, error)

	// Count 统计连接数(支持条件过滤)
	Count(ctx context.Context, opts *models.ConnectionQueryOptions) (int64, error)

	// GetConnectionStats 获取连接统计信息(仅 connect 身份维度,质量维度由 ConnectionQualityStore 补充)
	GetConnectionStats(ctx context.Context, startTime, endTime time.Time) (*models.ConnectionStats, error)

	// GetConnectionStatsByID 根据连接ID获取单个连接的统计信息
	GetConnectionStatsByID(ctx context.Context, connectionID string) (*models.UserConnectionStats, error)

	// GetUserConnectionStats 获取用户所有连接的汇总统计
	GetUserConnectionStats(ctx context.Context, userID string) (*models.UserConnectionStats, error)

	// GetNodeConnectionStats 获取节点连接统计
	GetNodeConnectionStats(ctx context.Context, nodeID string) (*models.NodeConnectionStats, error)

	// BatchUpsert 批量创建或更新连接记录
	BatchUpsert(ctx context.Context, records []*models.ConnectionRecord) error

	// CleanupInactiveRecords 清理非活跃记录
	CleanupInactiveRecords(ctx context.Context, before time.Time) (int64, error)

	// Close 关闭仓库,停止后台任务
	Close() error
}

ConnectionStore 连接记录存储接口

type DeliverEnvelope

type DeliverEnvelope struct {
	Intent        DeliverIntent      `json:"intent"`                   // 投递意图,空值消费端按路由字段推导
	Publisher     string             `json:"publisher"`                // 发布者标识(审计与 ACL 主体,必填)
	PublishedAt   time.Time          `json:"published_at"`             // 发布时刻(消费端时延监控)
	ExcludeSender bool               `json:"exclude_sender,omitempty"` // 广播排除发送者连接
	Hops          uint8              `json:"hops,omitempty"`           // 转发跳数防环
	Message       *models.HubMessage `json:"message"`
}

DeliverEnvelope 投递信封 - MQ 传输的外层包装

路由维度复用 HubMessage 自带字段(AppID/Namespace/GroupIDs/Receiver), 信封只补 ctx 侧参数、溯源与转发防环:

  • MessageID 必须非空:Go 发布者由 publisher 自动生成,异构发布者空值直接拒绝 (MessageID 是幂等去重键的一半,空值即去重失效)
  • Hops 转发跳数防环:消费端本地 miss 转投定向 topic 时 +1,超限按等级兜底 (必达走离线库 / 可靠记失败态 / express 丢弃)

type DeliverIntent

type DeliverIntent uint8

DeliverIntent 投递意图(决定 topic 路由与消费语义)

const (
	// IntentUnset 未设置(零值):消费端按 Message 路由字段(GroupIDs/Namespace)推导
	IntentUnset DeliverIntent = iota
	// IntentDeliver P2P / 群组投递(queue 语义消费)
	IntentDeliver
	// IntentBroadcast 全局/命名空间广播(fanout 语义消费)
	IntentBroadcast
)

type EventBus

type EventBus interface {
	// Publish 发布投递指令,同步返回表示 MQ 已接收(At-Least-Once 前提)
	// topic 路由由信封 Intent + Message 投递等级自动决定
	Publish(ctx context.Context, env *DeliverEnvelope) error

	// Subscribe 订阅投递指令,handler 返回 error 时按实现的 NACK 语义重投
	// queue 语义(TopicDeliver)与 fanout 语义(TopicBroadcast)由 topic 参数区分
	Subscribe(ctx context.Context, topic EventBusTopic, handler EventHandler) error

	// Close 优雅关闭:排空在途消息、退出订阅循环
	Close() error
}

EventBus 事件总线契约 - 下行投递指令的外部消息通道

语义分级(NATS 落地形态,其他适配器等价映射):

  • express(IsExpress)→ core subject:at-most-once,~0.3ms
  • reliable / 必达 → JetStream 分片:持久化 + 重投,分片内串行保序
  • broadcast → core fanout:每节点一份,只投本地连接

type EventBusTopic

type EventBusTopic string

EventBusTopic 订阅语义(Publish 侧由信封 Intent 自动路由,无需指定)

const (
	// TopicDeliver 投递语义:queue 消费(consumer group 负载均衡,任一节点消费)
	TopicDeliver EventBusTopic = "deliver"
	// TopicBroadcast 广播语义:fanout 消费(每节点独立消费一份,只投本地连接)
	TopicBroadcast EventBusTopic = "broadcast"
)

type EventHandler

type EventHandler func(ctx context.Context, env *DeliverEnvelope) error

EventHandler 投递指令消费回调

返回 nil 表示消费成功(适配器侧 ack);返回 error 触发 NACK 重投 (At-Least-Once 下消费方必须幂等,broker 域按投递等级分流去重)

type GroupStore

type GroupStore interface {
	// CreateGroup 创建群组(存储元信息,若已存在则覆盖)
	// group.AppID/Namespace 为空时自动填充默认值
	CreateGroup(ctx context.Context, group *models.Group) error

	// GetGroup 获取群组元信息
	GetGroup(ctx context.Context, appID, namespace, groupID string) (*models.Group, error)

	// DisbandGroup 解散群组(删除元信息、成员集合、命名空间索引及各成员的反向索引)
	DisbandGroup(ctx context.Context, appID, namespace, groupID string) error

	// AddMembers 添加成员到群组(同时更新成员的反向索引与命名空间索引)
	AddMembers(ctx context.Context, appID, namespace, groupID string, userIDs []string) error

	// RemoveMembers 从群组移除成员(同时清理成员的反向索引)
	RemoveMembers(ctx context.Context, appID, namespace, groupID string, userIDs []string) error

	// GetMembers 获取群组所有成员ID
	GetMembers(ctx context.Context, appID, namespace, groupID string) ([]string, error)

	// GetUserGroups 获取用户在指定 (appID, namespace) 下加入的所有群组ID
	GetUserGroups(ctx context.Context, appID, namespace, userID string) ([]string, error)

	// IsMember 判断用户是否为群组成员
	IsMember(ctx context.Context, appID, namespace, groupID, userID string) (bool, error)

	// GetMemberCount 获取群组成员数量
	GetMemberCount(ctx context.Context, appID, namespace, groupID string) (int64, error)

	// GetNamespaceGroups 获取 (appID, namespace) 下所有群组ID
	GetNamespaceGroups(ctx context.Context, appID, namespace string) ([]string, error)

	// GetAllNamespaces 获取指定 appID 下所有有群组的命名空间ID(用于该 app 的全命名空间广播)
	GetAllNamespaces(ctx context.Context, appID string) ([]string, error)

	// GetMultiGroupMembers 批量获取多个群组的成员(跨 namespace 聚合,两段 Pipeline 共 2 次网络往返)
	// 同一 groupID 可在多个 namespace 下各建实例(业务侧按租户建组),本方法按 (appID, groupIDs)
	// 跨 ns 聚合所有实例的成员并合并去重;返回 map[groupID][]memberIDs,无实例的 gid 该 key 缺失
	GetMultiGroupMembers(ctx context.Context, appID string, groupIDs []string) (map[string][]string, error)

	// EnsureSystemGroup 确保系统保留组存在(__ 前缀,agent/observer 自动加入前初始化)
	// 幂等:不存在则创建,已存在则返回 nil
	EnsureSystemGroup(ctx context.Context, appID, namespace, groupID string) error

	// SetInvalidateNotifier 注入拓扑写路径失效回调(拓扑写本地逐出后触发,供跨节点失效广播聚合)
	// 持本地缓存的实现(GroupMemberCache)存回调并在写路径统一触发;无本地缓存的裸仓储 no-op 丢弃
	SetInvalidateNotifier(fn func(appID, groupID string))

	// InvalidateTopology 逐出该群组的本地缓存拓扑(跨节点失效广播的消费入口)
	// 纯逐出不写负缓存:失效原因是拓扑变更(含建组/增成员),写负缓存会把新实例误报为无实例;
	// 无本地缓存的裸仓储 no-op
	InvalidateTopology(appID, groupID string)
}

GroupStore 群组存储接口

type HubStats

type HubStats interface {

	// UpdateConnectionStats 批量更新连接统计(总连接数+1、活跃连接数、心跳时间)
	UpdateConnectionStats(ctx context.Context, nodeID string, activeCount int64) error

	// IncrementTotalConnections 增加总连接数
	IncrementTotalConnections(ctx context.Context, nodeID string, delta int64) error

	// SetActiveConnections 设置当前活跃连接数
	SetActiveConnections(ctx context.Context, nodeID string, count int64) error

	// IncrementMessagesSent 增加已发送消息数
	IncrementMessagesSent(ctx context.Context, nodeID string, delta int64) error

	// IncrementMessagesReceived 增加已接收消息数
	IncrementMessagesReceived(ctx context.Context, nodeID string, delta int64) error

	// IncrementBroadcastsSent 增加已发送广播数
	IncrementBroadcastsSent(ctx context.Context, nodeID string, delta int64) error

	// RegisterNode 注册节点并初始化统计信息(设置启动时间、添加到节点集合)
	RegisterNode(ctx context.Context, nodeID string, startTime int64) error

	// GetNodeStats 获取指定节点的统计信息
	GetNodeStats(ctx context.Context, nodeID string) (*models.NodeStats, error)

	// GetAllNodesStats 获取所有节点的统计信息
	GetAllNodesStats(ctx context.Context) (map[string]*models.NodeStats, error)

	// GetTotalStats 获取集群总统计信息(所有节点汇总)
	GetTotalStats(ctx context.Context) (*models.ClusterStats, error)

	// CleanupNodeStats 清理已下线节点的统计数据
	CleanupNodeStats(ctx context.Context, nodeID string) error

	// UpdateNodeHeartbeat 更新节点心跳时间
	UpdateNodeHeartbeat(ctx context.Context, nodeID string) error

	// GetActiveNodes 获取活跃的节点列表(基于心跳)
	GetActiveNodes(ctx context.Context, timeout time.Duration) ([]string, error)
}

HubStats Hub 统计信息接口

职责:

  1. 节点级统计:连接数、消息收发、广播计数
  2. 集群级统计:所有节点汇总
  3. 节点注册发现:注册/注销、心跳、TTL 管理

存储维度:

  • 按 nodeID 隔离,key = "{prefix}node:{nodeID}"
  • 全局节点集合 key = "{prefix}nodes"

type Logger

type Logger logger.ILogger

Logger 日志接口,等价于 go-logger.ILogger

func InitLogger

func InitLogger(config *wscconfig.WSC) Logger

InitLogger 根据配置初始化日志器 Logging 未启用时回退 NewDefaultLogger,启用时使用配置构造的 go-logger 实例

func NewDefaultLogger

func NewDefaultLogger() Logger

NewDefaultLogger 返回默认日志器(进程内单例)

输出经 logger.NewConsoleWriter 批量合并,而非直写 os.Stdout: 直写形态下 go-logger 的 isConcurrentSafeOutput 对 os.Stdout 判定为 false (其并发安全未受保证),outLockFree 保持 false,于是每行一次互斥 + 一次 write(2)。 consoleLogWriter 实现 concurrentSafeMarker,WithOutput 会自动把 outLockFree 置为 true,互斥与逐行 syscall 一并消除。

实测(go-wsc 默认配置,真实文件,见 logger_bench_test.go): 串行 1280-1460ns → 215-221ns(约 6.0×),并行 1634-1890ns → 303-381ns(约 5.2×)。

⚠️ 缓冲意味着进程退出前必须刷出,否则丢失末尾日志。Hub 的 SafeShutdown 已挂 FlushDefaultLogger;不经 Hub 生命周期的调用方(如独立使用 adapter)须自行调用。

type MessageQueue

type MessageQueue interface {
	// Enqueue 入队消息
	Enqueue(ctx context.Context, queueName string, msg *models.HubMessage) error

	// Dequeue 出队消息(阻塞式,带看门狗锁保证消息不会因消费者崩溃而丢失)
	// timeout 为最长阻塞等待时间,超时返回 (nil, nil)
	Dequeue(ctx context.Context, queueName string, timeout time.Duration) (*models.HubMessage, error)

	// DequeueBatch 批量出队(非阻塞,单次往返)
	// 返回条数不足 count 表示队列已空
	DequeueBatch(ctx context.Context, queueName string, count int) ([]*models.HubMessage, error)

	// GetLength 获取队列当前长度
	GetLength(ctx context.Context, queueName string) (int64, error)

	// Clear 清空队列
	Clear(ctx context.Context, queueName string) error

	// Peek 查看队列头部消息(不移除)
	Peek(ctx context.Context, queueName string) (*models.HubMessage, error)
}

MessageQueue 通用消息队列契约

消费者按 queueName 分区,各分区互不干扰实现需保证同一 queueName 内 先进先出;跨 queueName 不保证顺序

type MessageRole

type MessageRole string

MessageRole 查询视角 —— 用户是消息的接收者还是发送者

const (
	// MessageRoleReceiver 作为接收者查询
	MessageRoleReceiver MessageRole = "receiver"
	// MessageRoleSender 作为发送者查询
	MessageRoleSender MessageRole = "sender"
)

type MessageSink

type MessageSink interface {

	// CreateBatch 批量创建消息发送记录(outbox 攒批 flush 用,一条 SQL 写整批;
	CreateBatch(ctx context.Context, records []*models.MessageSendRecord) error

	// FindByID 根据自增ID查找
	FindByID(ctx context.Context, id uint) (*models.MessageSendRecord, error)

	// FindByMessageID 根据消息ID+接收者复合键查找(P2P 同一 message_id 为每个 receiver 各建一条记录)
	FindByMessageID(ctx context.Context, key models.MessageRecordKey) (*models.MessageSendRecord, error)

	// QueryRecords 查询消息记录(支持按状态、发送者、接收者、节点IP、客户端IP等条件过滤)
	QueryRecords(ctx context.Context, filter *models.MessageRecordFilter) ([]*models.MessageSendRecord, error)

	// FindRetryable 查找可重试的记录
	FindRetryable(ctx context.Context, limit int) ([]*models.MessageSendRecord, error)

	// DeleteExpired 删除过期的记录
	DeleteExpired(ctx context.Context) (int64, error)

	// Delete 删除记录
	Delete(ctx context.Context, id uint) error

	// DeleteByMessageID 根据消息ID删除
	DeleteByMessageID(ctx context.Context, messageID string) error

	// BatchUpdateStatus 批量更新消息状态(按复合键批量定位;相同 status/reason/errorMsg 用一条 SQL;
	// 状态更新唯一路径,经 MessageStatusUpdater 攒批调用,单条 UpdateStatus 已移除)
	BatchUpdateStatus(ctx context.Context, keys []models.MessageRecordKey, status models.MessageSendStatus, reason models.FailureReason, errorMsg string) error

	// ClaimStaleSending 原子认领超时的 sending 记录(仅当状态仍为 sending 时按复合键更新)
	// 返回实际认领成功的复合键列表(状态已被并发方更新则不认领)
	ClaimStaleSending(ctx context.Context, keys []models.MessageRecordKey, newStatus models.MessageSendStatus, reason models.FailureReason, errorMsg string) ([]models.MessageRecordKey, error)

	// IncrementRetry 增加重试次数(按复合键定位单条记录)
	IncrementRetry(ctx context.Context, key models.MessageRecordKey, attempt models.RetryAttempt) error

	// GetStatistics 获取统计信息
	GetStatistics(ctx context.Context) (map[string]int64, error)

	// CleanupOld 清理旧记录
	CleanupOld(ctx context.Context, before time.Time) (int64, error)

	// Close 关闭仓库,停止后台任务
	Close() error
}

MessageSink 消息归档与统计接口

职责:

  1. MessageRecord:消息发送记录的 CRUD + 批量更新 + 统计
  2. HubStats:节点/集群级别的连接与消息统计

存储维度:

  • MessageRecord:按 message_id(全局唯一)主键,支持按 sender/receiver/node_ip/client_ip/status 过滤
  • HubStats:按 nodeID 隔离,key = "{prefix}node:{nodeID}",全局汇总 key = "{prefix}nodes"

type OfflineMessageFilter

type OfflineMessageFilter struct {
	// UserID 用户ID
	UserID string
	// Role 查询视角(接收者/发送者)
	Role MessageRole
	// AppID 应用ID(空表示查询所有应用)
	AppID string
	// Namespace 命名空间ID(空表示所有命名空间)
	Namespace string
	// GroupID 群组ID(空表示非群组消息或所有消息)
	GroupID string
	// Limit 数量限制
	Limit int
	// Cursor 分页游标(message_id)
	Cursor string
	// Statuses 消息状态列表,为空则待处理状态
	Statuses []models.MessageSendStatus
	// ExcludeExhausted 排除重试耗尽的消息(retry_count >= max_retry,毒消息死信)
	// 推送失败由 UpdatePushStatus 原子递增 retry_count,超限后不再进入推送流程
	// max_retry <= 0 视为不限制
	ExcludeExhausted bool
}

OfflineMessageFilter 离线消息查询条件

type OfflineQueue

type OfflineQueue interface {
	// StoreOfflineMessage 存储离线消息(双写 Redis + MySQL)
	// 落入 ctx 的 (ns, group) 对应的分区:Redis key = ns:group:userID
	StoreOfflineMessage(ctx context.Context, userID string, msg *models.HubMessage) error

	// DrainOfflineQueue 排空 Redis 队列(单组 FIFO,仅 Redis,不触及 MySQL)
	// 按 ctx 的单个 (ns, group) 出队,limit<=0 表示一次取尽
	// 调用方按组循环调用(hub replay 枚举用户所有 group + P2P)
	DrainOfflineQueue(ctx context.Context, userID string, limit int) ([]*models.HubMessage, error)

	// GetOfflineMessages 查询 MySQL 离线消息(跨组,命名空间内该用户全部 group)
	// limit: >0 最多返回指定数量;<=0 最多 1 万条
	// cursor: 上次返回的最后一条 message_id,空串表示从头
	// 返回 nextCursor 为空表示无更多数据
	GetOfflineMessages(ctx context.Context, userID string, limit int, cursor string) ([]*models.HubMessage, string, error)

	// DeleteOfflineMessages 按消息ID删除已推送的离线消息(MySQL,跨组按 message_id 删)
	DeleteOfflineMessages(ctx context.Context, userID string, messageIDs []string) error

	// GetOfflineMessageCount 获取离线消息数量(MySQL 跨组计数,MySQL 为双写超集故计数准确)
	GetOfflineMessageCount(ctx context.Context, userID string) (int64, error)

	// ClearOfflineMessages 清空用户的离线消息
	// groupIDs: 用户在该命名空间下的全部 group + "" (P2P),逐组清 Redis + 一次清 MySQL
	ClearOfflineMessages(ctx context.Context, userID string, groupIDs []string) error

	// UpdatePushStatus 更新离线消息推送状态
	// pushErr: 推送结果错误,nil表示成功,非nil表示失败
	UpdatePushStatus(ctx context.Context, messageIDs []string, pushErr error) error
}

OfflineQueue 离线消息处理器接口(业务逻辑层)

存储维度:按 (appID, namespace, groupID, userID) 四元组隔离

  • Redis 队列 key = "{prefix}{appID}:{ns}:{groupID}:{userID}"(P2P 消息 groupID 为空)
  • MySQL 记录带 app_id + namespace + group_id + receiver 列

appID 是最上层隔离维度(默认 "__default_app__"),namespace + groupID 从 ctx 路由元数据提取 (hub 层 routing.NewRoute().WithAppID(...).Inject(ctx) 注入)appID 严格隔离,跨 app 不串扰

type OfflineStore

type OfflineStore interface {
	// Save 保存离线消息
	Save(ctx context.Context, record *models.OfflineMessageRecord) error

	// BatchSave 批量保存离线消息
	BatchSave(ctx context.Context, records []*models.OfflineMessageRecord) error

	// QueryMessages 查询离线消息(支持按接收者/发送者、分页、状态过滤)
	QueryMessages(ctx context.Context, filter *OfflineMessageFilter) ([]*models.OfflineMessageRecord, error)

	// DeleteByMessageIDs 批量删除离线消息(按应用+命名空间+接收者)
	DeleteByMessageIDs(ctx context.Context, appID, namespace, receiverID string, messageIDs []string) error

	// GetCountByReceiver 获取用户作为接收者的离线消息数量
	GetCountByReceiver(ctx context.Context, appID, namespace, receiverID string) (int64, error)

	// GetCountBySender 获取用户作为发送者的离线消息数量
	GetCountBySender(ctx context.Context, appID, namespace, senderID string) (int64, error)

	// ClearByReceiver 清空用户作为接收者的所有离线消息
	ClearByReceiver(ctx context.Context, appID, namespace, receiverID string) error

	// DeleteExpired 删除过期的离线消息
	DeleteExpired(ctx context.Context) (int64, error)

	// UpdatePushStatus 更新离线消息推送状态
	// status: 消息状态(pending/success/failed)
	// errorMsg: 错误信息(失败时)
	UpdatePushStatus(ctx context.Context, messageIDs []string, status models.MessageSendStatus, errorMsg string) error

	// CleanupOld 清理 before 之前的旧记录
	CleanupOld(ctx context.Context, before time.Time) (int64, error)

	// Close 关闭仓库,停止后台任务
	Close() error
}

OfflineStore 离线消息的 RDBMS 持久化契约

所有查询/删除均按 (appID, namespace, userID) 三元组隔离; appID 是最上层隔离维度,跨 app 绝不串扰

type OnlineStore

type OnlineStore interface {

	// SetClientOnline 设置客户端在线(支持多设备)
	SetClientOnline(ctx context.Context, client *models.Client) error

	// SetClientOffline 设置指定客户端离线
	SetClientOffline(ctx context.Context, client *models.Client) error

	// SetOffline 设置用户所有客户端离线
	SetOffline(ctx context.Context, userID string) error

	// GetClient 获取客户端信息
	GetClient(ctx context.Context, clientID string) (*models.Client, error)

	// GetClientOwner 获取 clientID 当前归属节点(上线脚本写入 owner key)
	// 返回空串表示无归属记录(旧数据或已过期);用于检测同 clientID 跨节点迁移
	GetClientOwner(ctx context.Context, clientID string) (string, error)

	// GetUserClients 获取用户的所有在线客户端
	GetUserClients(ctx context.Context, userID string) ([]*models.Client, error)

	// UpdateClientHeartbeat 更新客户端心跳
	UpdateClientHeartbeat(ctx context.Context, clientID string) error

	// IsUserOnline 检查用户是否在线(任意设备,按 ctx 路由信封 appID+namespace 隔离)
	// 路径自适应:有路由信封时走 L1 offset 缓存 → GETBIT(命中零网络),
	// 无路由信封走 unscoped GETBIT;位即真相,无 ZSET 兜底
	IsUserOnline(ctx context.Context, userID string) (bool, error)

	// BatchIsUserOnline 批量在线判定,返回 map[userID]bool(含全部查询的 userID)
	// Pipeline 实现:offset 解析(L1+HGET)→ GETBIT,两次往返覆盖 N 个用户(全命中 L1 时一次)
	BatchIsUserOnline(ctx context.Context, userIDs []string) (map[string]bool, error)

	// GetAllOnlineUsers 获取所有在线用户ID列表
	GetAllOnlineUsers(ctx context.Context) ([]string, error)

	// GetOnlineCount 获取在线用户总数
	GetOnlineCount(ctx context.Context) (int64, error)

	// GetOnlineUsersByType 根据用户类型获取在线用户
	GetOnlineUsersByType(ctx context.Context, userType models.UserType) ([]string, error)

	// GetUserNodes 获取用户所在的所有节点(支持多设备)
	GetUserNodes(ctx context.Context, userID string) ([]string, error)

	// BatchGetUserNodes 批量获取多个用户所在的所有节点(Pipeline 优化,避免 N+1 查询)
	// 返回 map[userID][]nodeIDs,未找到的 userID 不在 map 中
	BatchGetUserNodes(ctx context.Context, userIDs []string) (map[string][]string, error)

	// GetNodeClients 获取节点的所有在线客户端
	GetNodeClients(ctx context.Context, nodeID string) ([]*models.Client, error)

	// GetNodeUsers 获取节点的所有在线用户ID
	GetNodeUsers(ctx context.Context, nodeID string) ([]string, error)

	// BatchSetClientsOnline 批量设置客户端在线(全量:JSON 序列化 + SETEX client 详情)
	BatchSetClientsOnline(ctx context.Context, clients []*models.Client) error

	// RenewClientsOnline 心跳批量续期(轻量路径:跳过序列化/压缩/SETEX,仅续期索引与 bitmap)
	// client:<id> 键缺失(过期/淘汰)的客户端内部自动走全量重建,自愈语义与 BatchSetClientsOnline 一致
	// 供心跳高频刷新使用,千万级连接下避免每周期全量重写 client 详情
	RenewClientsOnline(ctx context.Context, clients []*models.Client) error

	// BatchSetClientsOffline 批量设置客户端离线
	BatchSetClientsOffline(ctx context.Context, clientIDs []string) error

	// BatchSetClientsOfflineWithInfo 批量设置客户端离线(使用已知的客户端信息)
	// 客户端信息已知时使用,避免从 Redis 查询,确保即使 client key 已被删除也能清理 bitmap 位与索引
	BatchSetClientsOfflineWithInfo(ctx context.Context, clients []*models.Client) error

	// CleanupExpired 清理当前节点的过期客户端
	CleanupExpired(ctx context.Context, nodeID string) (int64, error)
}

OnlineStore 在线状态仓库接口

type RouteAdvisor

type RouteAdvisor interface {
	// NodeOf 返回用户连接所在节点;空列表 = 离线/未知(投共享 topic 或直接离线)
	NodeOf(ctx context.Context, appID, namespace, userID string) []string
}

RouteAdvisor 路由查询端口 - 发布侧粘性定向(L0)的依据

实现:redisadapter 基于在线索引(GetUserNodes)+ 本地短 TTL 缓存; 未注入时 publisher 自动降级投共享通道,行为不变——粘性是性能优化 不是正确性前提

type StoreDeps

type StoreDeps struct {
	// Redis Redis 客户端(纯 RDBMS 部署时为 nil)
	Redis redis.UniversalClient
	// DB GORM 实例(纯 Redis 部署时为 nil)
	DB *gorm.DB
}

StoreDeps 装配依赖 —— 一次交给适配器,由适配器各取所需

不拆成「Redis 一份、RDBMS 一份」两个方法:离线消息处理器这类组件 同时需要队列(Redis)与持久化(RDBMS),拆开后任何单个 hook 都拿不到 完整依赖,只能靠调用方在外部手工补齐,反而把装配逻辑漏回业务侧

type StoreHooks

type StoreHooks interface {
	// Configure 用给定依赖装配该适配器负责的全部仓库
	//
	// 返回:
	//   - error: 仅在该适配器所需依赖齐备却构造失败时返回;
	//     依赖缺失(如纯 Redis 场景无 DB)应直接跳过并返回 nil
	Configure(ctx context.Context, hub StoreTarget, deps StoreDeps) error
}

StoreHooks 存储层装配钩子

每个适配器包提供一个实现,用自己的依赖构造仓库实现并注入 Hub Hub 侧只认本接口,不认识任何具体适配器类型

依赖缺失时应跳过而非报错:纯 Redis 部署下 gorm 适配器的 hook 只要 检测到 deps.DB == nil 就返回 nil,不影响另一侧装配

type StoreTarget

type StoreTarget interface {
	SetOnlineStatusRepository(store OnlineStore)
	SetHubStatsRepository(store HubStats)
	SetGroupRepository(store GroupStore)
	SetWorkloadRepository(store WorkloadStore)
	SetMessageRecordRepository(sink MessageSink)
	SetConnectionRecordRepository(store ConnectionStore)
	SetConnectionQualityRepository(store ConnectionQualityStore)
	SetOfflineMessageHandler(queue OfflineQueue)
}

StoreTarget 装配目标 —— 适配器通过这些 setter 把实现交给 Hub

能力面按「适配器实际需要注入哪些仓库」裁剪,而非抄 Hub 的完整 setter 表

type WorkloadStore

type WorkloadStore interface {
	// ReloadAgentWorkload 重新加载客服工作负载(客服上线时调用)
	// 优先级:Redis > DB > 默认值 0;从持久化存储恢复负载,不存在则初始化为 0 并同步 ZSet
	ReloadAgentWorkload(ctx context.Context, agentID string) (int64, error)

	// ForceSetAgentWorkload 强制设置客服工作负载(慎用,会覆盖现有值)
	// 直接覆盖 Redis 和 ZSet 中的负载值,异步同步到 DB
	ForceSetAgentWorkload(ctx context.Context, agentID string, workload int64) error

	// GetAgentWorkload 获取客服工作负载
	GetAgentWorkload(ctx context.Context, agentID string) (int64, error)

	// IncrementAgentWorkload 增加客服工作负载
	IncrementAgentWorkload(ctx context.Context, agentID string) error

	// DecrementAgentWorkload 减少客服工作负载
	DecrementAgentWorkload(ctx context.Context, agentID string) error

	// GetLeastLoadedAgent 获取负载最小的在线客服
	GetLeastLoadedAgent(ctx context.Context, onlineAgents []string, dimension models.WorkloadDimension) (string, int64, error)

	// AcquireLeastLoadedAgent 原子地选择负载最小的在线客服并将其负载 +1
	//
	// 与 GetLeastLoadedAgent 的区别:
	//   - GetLeastLoadedAgent 只读,纯查询,返回后业务侧还需单独调用 IncrementAgentWorkload,
	//     这会导致"读-改-写"之间存在 TOCTOU 窗口,多进程/多 goroutine 并发时读到同一份旧负载快照,
	//     选出同一个客服,造成工单扎堆分配到同一人(负载均衡不均)
	//   - AcquireLeastLoadedAgent 在同一段 Lua 中完成 "选中 + 所有维度原子 +1",
	//     Redis 单线程保证跨进程原子,是分布式场景下负载均衡分配的正确做法
	//
	// 返回值:
	//   - agentID: 被选中的客服 ID
	//   - workload: 选中客服在选中前的 realtime 负载值(用于日志/监控)
	//   - error: 执行失败的错误
	//
	// 业务失败回滚:调用方应在后续业务失败时调用 DecrementAgentWorkload 回滚此次预扣减
	AcquireLeastLoadedAgent(ctx context.Context, onlineAgents []string, dimension models.WorkloadDimension) (string, int64, error)

	// RemoveAgentWorkload 移除客服负载记录(客服下线时调用)
	// 只删除 ZSet 记录,保留 string key 以便重新上线时恢复
	RemoveAgentWorkload(ctx context.Context, agentID string) error

	// GetAllAgentWorkloads 获取所有客服负载(按负载升序)
	GetAllAgentWorkloads(ctx context.Context, limit int64) ([]models.WorkloadInfo, error)
}

WorkloadStore 客服负载存储接口

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL