DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,91 @@
package presence
import (
"context"
"strings"
"time"
"longheng.io/server/internal/platform/metrics"
)
const (
// MetricPresenceCount 是在线 session 数 gauge 名称。
MetricPresenceCount = "presence_count"
// MetricUserCount 是在线用户数 gauge 名称。
MetricUserCount = "presence_user_count"
// MetricSubscriptionCount 是订阅关系数 gauge 名称。
MetricSubscriptionCount = "presence_subscription_count"
)
// MetricsObserver 表示底层 runtime 暴露了适合 Prometheus scrape 的 gauge。
// Manager 和 RedisManager 都实现该接口,wiring 代码可以通过类型断言挂到
// metrics.Registry,而不需要绑定具体实现。
type MetricsObserver interface {
ObserveMetrics(reg *metrics.Registry, labels map[string]string)
}
const defRedisObsTimeout = 2 * time.Second
// ObserveMetrics 基于内存快照把 presence_count / presence_user_count /
// presence_subscription_count 发布为 gauge。它只持有 RLock、没有 IO,
// 可以安全用于 scrape 路径。
func (m *Manager) ObserveMetrics(reg *metrics.Registry, labels map[string]string) {
if m == nil || reg == nil {
return
}
snapshot := m.Snapshot()
setPresenceGauges(reg, labels, int64(snapshot.PresenceCount), int64(snapshot.UserCount), int64(snapshot.SubscriptionCount))
}
// ObserveMetrics 对共享 Redis presence key 执行两次 SCARD。
// SCARD 是 O(1),适合 scrape 路径;这里仍用短超时包住 Redis 访问:
// Redis 失败时本次 gauge 不更新,而不是阻塞 metrics endpoint。
func (m *RedisManager) ObserveMetrics(reg *metrics.Registry, labels map[string]string) {
if m == nil || reg == nil {
return
}
m.ensureReady()
if m.executor == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), defRedisObsTimeout)
defer cancel()
presences, err := m.redisInt(ctx, "SCARD", m.sessionsIndexKey())
if err != nil {
return
}
users, err := m.redisInt(ctx, "SCARD", m.usersIndexKey())
if err != nil {
return
}
subscriptions, err := m.redisInt(ctx, "SCARD", m.subIndexKey())
if err != nil {
return
}
setPresenceGauges(reg, labels, presences, users, subscriptions)
}
func setPresenceGauges(reg *metrics.Registry, labels map[string]string, presences, users, subscriptions int64) {
gaugeLabels := normObserverLabels(labels)
reg.SetGauge(MetricPresenceCount, gaugeLabels, presences)
reg.SetGauge(MetricUserCount, gaugeLabels, users)
reg.SetGauge(MetricSubscriptionCount, gaugeLabels, subscriptions)
}
func normObserverLabels(labels map[string]string) map[string]string {
if len(labels) == 0 {
return nil
}
out := make(map[string]string, len(labels))
for key, value := range labels {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = value
}
if len(out) == 0 {
return nil
}
return out
}
@@ -0,0 +1,718 @@
package presence
import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
)
var (
// ErrInvalidUserID 表示用户 ID 为空或非法。
ErrInvalidUserID = errors.New("presence user id is required")
// ErrInvalidSessionID 表示 session ID 为空或非法。
ErrInvalidSessionID = errors.New("presence session id is required")
// ErrInvalidStatus 表示在线状态为空。
ErrInvalidStatus = errors.New("presence status is required")
// ErrNilSender 表示广播发送函数为空。
ErrNilSender = errors.New("presence sender is required")
// ErrManagerRequired 表示内存版 presence manager 为空。
ErrManagerRequired = errors.New("presence manager is required")
)
const (
// StatusOnline 是默认在线状态。
StatusOnline = "online"
defPresenceShards = 64
)
// Runtime 定义 presence 模块对网关和社交服务暴露的在线状态能力。
type Runtime interface {
Track(context.Context, Presence) (Presence, error)
Untrack(context.Context, string, string) (Presence, bool, error)
Touch(context.Context, string, string, time.Time) (Presence, bool, error)
SetStatus(context.Context, string, string, string, map[string]string) (Presence, bool, error)
Presence(context.Context, string) ([]Presence, error)
Session(context.Context, string) (Presence, bool, error)
Subscribe(context.Context, string, string) error
Unsubscribe(context.Context, string, string) error
Subscribers(context.Context, string) ([]string, error)
Subscriptions(context.Context, string) ([]string, error)
Broadcast(context.Context, string, SendFunc) error
Snapshot() Snapshot
}
// Presence 描述一个用户在某个 session 上的在线状态。
type Presence struct {
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
NodeID string `json:"node_id,omitempty"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// Subscription 表示一个订阅者关注某个目标用户在线状态。
type Subscription struct {
SubscriberID string `json:"subscriber_id"`
TargetID string `json:"target_id"`
}
// Snapshot 是 presence manager 的一致性快照。
type Snapshot struct {
Name string `json:"name"`
PresenceCount int `json:"presence_count"`
UserCount int `json:"user_count"`
SubscriptionCount int `json:"subscription_count"`
Presences []Presence `json:"presences,omitempty"`
Subscriptions []Subscription `json:"subscriptions,omitempty"`
}
// SendFunc 是 Broadcast fanout 时调用的发送函数。
type SendFunc func(context.Context, string, []Presence) error
// Options 控制内存版 presence manager 的名称、时间源和分片数。
type Options struct {
Name string
Now func() time.Time
ShardCount int
}
// Manager 维护本机 session、用户在线状态和订阅关系索引。
type Manager struct {
initOnce sync.Once
shardCount int
name string
now func() time.Time
// opMu 保护 session->presence 与 user->sessions 双索引的跨分片更新;心跳和状态刷新只锁单个 session 分片。
opMu sync.Mutex
presenceShards []presenceShard
userShards []userSessionShard
subMu sync.RWMutex
subscriptions map[string]map[string]struct{}
subscribers map[string]map[string]struct{}
}
type presenceShard struct {
mu sync.RWMutex
presences map[string]Presence
}
type userSessionShard struct {
mu sync.RWMutex
userSessions map[string]map[string]struct{}
}
// New 创建内存版 presence manager。
func New(options Options) *Manager {
name := strings.TrimSpace(options.Name)
if name == "" {
name = "presence"
}
now := options.Now
if now == nil {
now = time.Now
}
shardCount := options.ShardCount
if shardCount <= 0 {
shardCount = defPresenceShards
}
m := &Manager{
name: name,
now: now,
shardCount: shardCount,
}
m.ensureReady()
return m
}
// Track 注册或替换某个 session 的在线状态。
func (m *Manager) Track(ctx context.Context, item Presence) (Presence, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, err
}
if m == nil {
return Presence{}, ErrManagerRequired
}
m.ensureReady()
item, err := normalizePresence(item, m.now().UTC())
if err != nil {
return Presence{}, err
}
m.opMu.Lock()
defer m.opMu.Unlock()
sessionShard := m.presenceShard(item.SessionID)
sessionShard.mu.Lock()
old, hadOld := sessionShard.presences[item.SessionID]
sessionShard.presences[item.SessionID] = item
sessionShard.mu.Unlock()
if hadOld && old.UserID != item.UserID {
m.removeUserSession(old.UserID, old.SessionID)
}
m.addUserSession(item.UserID, item.SessionID)
return clonePresence(item), nil
}
// Untrack 移除指定 session 的在线状态;userID 非空时必须匹配当前绑定。
func (m *Manager) Untrack(ctx context.Context, userID, sessionID string) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if m == nil {
return Presence{}, false, ErrManagerRequired
}
m.ensureReady()
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
m.opMu.Lock()
defer m.opMu.Unlock()
sessionShard := m.presenceShard(sessionID)
sessionShard.mu.Lock()
item, ok := sessionShard.presences[sessionID]
if !ok {
sessionShard.mu.Unlock()
return Presence{}, false, nil
}
// 如果调用方带 userID,必须匹配当前绑定,防止旧 gateway 的断开事件误删新绑定。
if userID != "" && item.UserID != userID {
sessionShard.mu.Unlock()
return Presence{}, false, nil
}
delete(sessionShard.presences, sessionID)
sessionShard.mu.Unlock()
m.removeUserSession(item.UserID, sessionID)
return clonePresence(item), true, nil
}
// Touch 刷新指定 session 的在线更新时间。
func (m *Manager) Touch(ctx context.Context, userID, sessionID string, at time.Time) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if m == nil {
return Presence{}, false, ErrManagerRequired
}
m.ensureReady()
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
if at.IsZero() {
at = m.now()
}
sessionShard := m.presenceShard(sessionID)
sessionShard.mu.Lock()
defer sessionShard.mu.Unlock()
item, ok := sessionShard.presences[sessionID]
if !ok {
return Presence{}, false, nil
}
// Touch 只允许当前 user/session 刷新 LastSeen,旧连接的心跳不能延长新 session。
if userID != "" && item.UserID != userID {
return Presence{}, false, nil
}
item.UpdatedAt = at.UTC()
sessionShard.presences[sessionID] = item
return clonePresence(item), true, nil
}
// SetStatus 更新指定 session 的在线状态和元数据。
func (m *Manager) SetStatus(ctx context.Context, userID, sessionID, status string, metadata map[string]string) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if m == nil {
return Presence{}, false, ErrManagerRequired
}
m.ensureReady()
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
status = strings.TrimSpace(status)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
if status == "" {
return Presence{}, false, ErrInvalidStatus
}
sessionShard := m.presenceShard(sessionID)
sessionShard.mu.Lock()
defer sessionShard.mu.Unlock()
item, ok := sessionShard.presences[sessionID]
if !ok {
return Presence{}, false, nil
}
// SetStatus 与 Touch 使用同一绑定校验;metadata=nil 表示清空而不是保留旧值。
if userID != "" && item.UserID != userID {
return Presence{}, false, nil
}
item.Status = status
item.Metadata = normalizeMetadata(metadata)
item.UpdatedAt = m.now().UTC()
sessionShard.presences[sessionID] = item
return clonePresence(item), true, nil
}
// Presence 查询某个用户当前所有在线 session。
func (m *Manager) Presence(ctx context.Context, userID string) ([]Presence, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if m == nil {
return nil, ErrManagerRequired
}
m.ensureReady()
userID = strings.TrimSpace(userID)
if userID == "" {
return nil, ErrInvalidUserID
}
out := m.presencesByUser(userID)
sortPresences(out)
return out, nil
}
// Session 查询指定 session 的在线状态。
func (m *Manager) Session(ctx context.Context, sessionID string) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if m == nil {
return Presence{}, false, ErrManagerRequired
}
m.ensureReady()
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
sessionShard := m.presenceShard(sessionID)
sessionShard.mu.RLock()
item, ok := sessionShard.presences[sessionID]
sessionShard.mu.RUnlock()
if !ok {
return Presence{}, false, nil
}
return clonePresence(item), true, nil
}
// Subscribe 订阅目标用户在线状态变化。
func (m *Manager) Subscribe(ctx context.Context, subscriberID, targetID string) error {
if err := ctxErr(ctx); err != nil {
return err
}
if m == nil {
return ErrManagerRequired
}
m.ensureReady()
subscriberID, targetID, err := normSubIDs(subscriberID, targetID)
if err != nil {
return err
}
m.subMu.Lock()
defer m.subMu.Unlock()
if m.subscriptions[subscriberID] == nil {
m.subscriptions[subscriberID] = make(map[string]struct{})
}
if m.subscribers[targetID] == nil {
m.subscribers[targetID] = make(map[string]struct{})
}
m.subscriptions[subscriberID][targetID] = struct{}{}
m.subscribers[targetID][subscriberID] = struct{}{}
return nil
}
// Unsubscribe 取消订阅目标用户在线状态变化。
func (m *Manager) Unsubscribe(ctx context.Context, subscriberID, targetID string) error {
if err := ctxErr(ctx); err != nil {
return err
}
if m == nil {
return ErrManagerRequired
}
m.ensureReady()
subscriberID, targetID, err := normSubIDs(subscriberID, targetID)
if err != nil {
return err
}
m.subMu.Lock()
defer m.subMu.Unlock()
if targets := m.subscriptions[subscriberID]; targets != nil {
delete(targets, targetID)
if len(targets) == 0 {
delete(m.subscriptions, subscriberID)
}
}
if watchers := m.subscribers[targetID]; watchers != nil {
delete(watchers, subscriberID)
if len(watchers) == 0 {
delete(m.subscribers, targetID)
}
}
return nil
}
// Subscribers 返回订阅目标用户的所有订阅者。
func (m *Manager) Subscribers(ctx context.Context, targetID string) ([]string, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if m == nil {
return nil, ErrManagerRequired
}
m.ensureReady()
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, ErrInvalidUserID
}
m.subMu.RLock()
out := sortedSet(m.subscribers[targetID])
m.subMu.RUnlock()
return out, nil
}
// Subscriptions 返回订阅者关注的所有目标用户。
func (m *Manager) Subscriptions(ctx context.Context, subscriberID string) ([]string, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if m == nil {
return nil, ErrManagerRequired
}
m.ensureReady()
subscriberID = strings.TrimSpace(subscriberID)
if subscriberID == "" {
return nil, ErrInvalidUserID
}
m.subMu.RLock()
out := sortedSet(m.subscriptions[subscriberID])
m.subMu.RUnlock()
return out, nil
}
// Broadcast 将目标用户当前在线状态广播给所有订阅者。
func (m *Manager) Broadcast(ctx context.Context, targetID string, send SendFunc) error {
if send == nil {
return ErrNilSender
}
if err := ctxErr(ctx); err != nil {
return err
}
if m == nil {
return ErrManagerRequired
}
m.ensureReady()
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return ErrInvalidUserID
}
// Broadcast 对当前在线 session 快照逐个发送,发送失败即返回;它不是可靠离线推送。
m.subMu.RLock()
subscribers := sortedSet(m.subscribers[targetID])
m.subMu.RUnlock()
presences := m.presencesByUser(targetID)
sortPresences(presences)
for _, subscriberID := range subscribers {
if err := ctxErr(ctx); err != nil {
return err
}
if err := send(ctx, subscriberID, clonePresences(presences)); err != nil {
return err
}
}
return nil
}
// Snapshot 返回 presence manager 当前快照。
func (m *Manager) Snapshot() Snapshot {
if m == nil {
return Snapshot{}
}
m.ensureReady()
presences := make([]Presence, 0)
for i := range m.presenceShards {
shard := &m.presenceShards[i]
shard.mu.RLock()
for _, item := range shard.presences {
presences = append(presences, clonePresence(item))
}
shard.mu.RUnlock()
}
m.subMu.RLock()
subscriptions := make([]Subscription, 0)
for subscriberID, targets := range m.subscriptions {
for targetID := range targets {
subscriptions = append(subscriptions, Subscription{
SubscriberID: subscriberID,
TargetID: targetID,
})
}
}
m.subMu.RUnlock()
sortPresences(presences)
sortSubscriptions(subscriptions)
return Snapshot{
Name: m.name,
PresenceCount: len(presences),
UserCount: countPresenceUsers(presences),
SubscriptionCount: len(subscriptions),
Presences: presences,
Subscriptions: subscriptions,
}
}
func (m *Manager) ensureReady() {
m.initOnce.Do(func() {
m.name = strings.TrimSpace(m.name)
if m.name == "" {
m.name = "presence"
}
if m.now == nil {
m.now = time.Now
}
shardCount := m.shardCount
if shardCount <= 0 {
switch {
case len(m.presenceShards) > 0:
shardCount = len(m.presenceShards)
case len(m.userShards) > 0:
shardCount = len(m.userShards)
default:
shardCount = defPresenceShards
}
}
if len(m.presenceShards) == 0 {
m.presenceShards = make([]presenceShard, shardCount)
}
if len(m.userShards) == 0 {
m.userShards = make([]userSessionShard, shardCount)
}
m.shardCount = len(m.presenceShards)
for i := range m.presenceShards {
if m.presenceShards[i].presences == nil {
m.presenceShards[i].presences = make(map[string]Presence)
}
}
for i := range m.userShards {
if m.userShards[i].userSessions == nil {
m.userShards[i].userSessions = make(map[string]map[string]struct{})
}
}
if m.subscriptions == nil {
m.subscriptions = make(map[string]map[string]struct{})
}
if m.subscribers == nil {
m.subscribers = make(map[string]map[string]struct{})
}
})
}
func (m *Manager) addUserSession(userID, sessionID string) {
shard := m.userShard(userID)
shard.mu.Lock()
if shard.userSessions[userID] == nil {
shard.userSessions[userID] = make(map[string]struct{})
}
shard.userSessions[userID][sessionID] = struct{}{}
shard.mu.Unlock()
}
func (m *Manager) removeUserSession(userID, sessionID string) {
shard := m.userShard(userID)
shard.mu.Lock()
if sessions := shard.userSessions[userID]; sessions != nil {
delete(sessions, sessionID)
if len(sessions) == 0 {
delete(shard.userSessions, userID)
}
}
shard.mu.Unlock()
}
func (m *Manager) presencesByUser(userID string) []Presence {
m.opMu.Lock()
defer m.opMu.Unlock()
userShard := m.userShard(userID)
userShard.mu.RLock()
sessions := sortedSet(userShard.userSessions[userID])
userShard.mu.RUnlock()
presences := make([]Presence, 0, len(sessions))
for _, sessionID := range sessions {
sessionShard := m.presenceShard(sessionID)
sessionShard.mu.RLock()
item, ok := sessionShard.presences[sessionID]
sessionShard.mu.RUnlock()
if ok && item.UserID == userID {
presences = append(presences, clonePresence(item))
}
}
return presences
}
func countPresenceUsers(presences []Presence) int {
if len(presences) == 0 {
return 0
}
users := make(map[string]struct{}, len(presences))
for _, item := range presences {
users[item.UserID] = struct{}{}
}
return len(users)
}
func (m *Manager) presenceShard(sessionID string) *presenceShard {
return &m.presenceShards[shardIndex(sessionID, len(m.presenceShards))]
}
func (m *Manager) userShard(userID string) *userSessionShard {
return &m.userShards[shardIndex(userID, len(m.userShards))]
}
func shardIndex(key string, shardCount int) int {
if shardCount <= 1 {
return 0
}
var hash uint32 = 2166136261
for i := 0; i < len(key); i++ {
hash ^= uint32(key[i])
hash *= 16777619
}
return int(hash % uint32(shardCount)) //nolint:gosec // G115:取模结果严格小于 shardCount,可安全作为 shard 下标。
}
func normalizePresence(item Presence, now time.Time) (Presence, error) {
item.UserID = strings.TrimSpace(item.UserID)
item.SessionID = strings.TrimSpace(item.SessionID)
item.NodeID = strings.TrimSpace(item.NodeID)
item.Status = strings.TrimSpace(item.Status)
if item.UserID == "" {
return Presence{}, ErrInvalidUserID
}
if item.SessionID == "" {
return Presence{}, ErrInvalidSessionID
}
if item.Status == "" {
item.Status = StatusOnline
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = now
} else {
item.UpdatedAt = item.UpdatedAt.UTC()
}
item.Metadata = normalizeMetadata(item.Metadata)
return item, nil
}
func normSubIDs(subscriberID, targetID string) (string, string, error) {
subscriberID = strings.TrimSpace(subscriberID)
targetID = strings.TrimSpace(targetID)
if subscriberID == "" || targetID == "" {
return "", "", ErrInvalidUserID
}
return subscriberID, targetID, nil
}
func normalizeMetadata(metadata map[string]string) map[string]string {
if len(metadata) == 0 {
return nil
}
out := make(map[string]string, len(metadata))
for key, value := range metadata {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil
}
return out
}
func clonePresence(item Presence) Presence {
item.Metadata = cloneMetadata(item.Metadata)
return item
}
func clonePresences(items []Presence) []Presence {
if len(items) == 0 {
return nil
}
out := make([]Presence, len(items))
for i := range items {
out[i] = clonePresence(items[i])
}
return out
}
func cloneMetadata(metadata map[string]string) map[string]string {
if len(metadata) == 0 {
return nil
}
out := make(map[string]string, len(metadata))
for key, value := range metadata {
out[key] = value
}
return out
}
func sortedSet(values map[string]struct{}) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}
func sortPresences(items []Presence) {
sort.Slice(items, func(i, j int) bool {
if items[i].UserID != items[j].UserID {
return items[i].UserID < items[j].UserID
}
return items[i].SessionID < items[j].SessionID
})
}
func sortSubscriptions(items []Subscription) {
sort.Slice(items, func(i, j int) bool {
if items[i].SubscriberID != items[j].SubscriberID {
return items[i].SubscriberID < items[j].SubscriberID
}
return items[i].TargetID < items[j].TargetID
})
}
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
+924
View File
@@ -0,0 +1,924 @@
package presence
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/db"
)
// ErrNilRedisExecutor 表示 Redis presence manager 没有可用执行器。
var ErrNilRedisExecutor = errors.New("presence redis executor is required")
// redisPresenceTrack 原子维护 session、user 和反向索引。
const redisPresenceTrack = `
local presence_key = KEYS[1]
local session_user_key = KEYS[2]
local sessions_index_key = KEYS[3]
local users_index_key = KEYS[4]
local user_sessions_key = KEYS[5]
local user_sessions_prefix = ARGV[1]
local data = ARGV[2]
local user_id = ARGV[3]
local session_id = ARGV[4]
local ttl = tonumber(ARGV[5]) or 0
local old_user_id = redis.call("GET", session_user_key)
if old_user_id and old_user_id ~= user_id then
local old_user_sessions_key = user_sessions_prefix .. old_user_id
redis.call("SREM", old_user_sessions_key, session_id)
if redis.call("SCARD", old_user_sessions_key) == 0 then
redis.call("SREM", users_index_key, old_user_id)
end
end
if ttl > 0 then
redis.call("SET", presence_key, data, "EX", ttl)
redis.call("SET", session_user_key, user_id, "EX", ttl)
else
redis.call("SET", presence_key, data)
redis.call("SET", session_user_key, user_id)
end
redis.call("SADD", sessions_index_key, session_id)
redis.call("SADD", users_index_key, user_id)
redis.call("SADD", user_sessions_key, session_id)
return "OK"
`
// redisPresenceRemove 会校验 expected_user_id,防止旧 gateway 把新绑定的 session 删除。
const redisPresenceRemove = `
local presence_key = KEYS[1]
local session_user_key = KEYS[2]
local sessions_index_key = KEYS[3]
local users_index_key = KEYS[4]
local user_sessions_prefix = ARGV[1]
local session_id = ARGV[2]
local expected_user_id = ARGV[3]
local user_id = redis.call("GET", session_user_key)
if user_id and user_id ~= "" and expected_user_id ~= "" and user_id ~= expected_user_id then
return "mismatch"
end
if not user_id or user_id == "" then
user_id = expected_user_id
end
redis.call("DEL", presence_key)
redis.call("DEL", session_user_key)
redis.call("SREM", sessions_index_key, session_id)
if user_id and user_id ~= "" then
local user_sessions_key = user_sessions_prefix .. user_id
redis.call("SREM", user_sessions_key, session_id)
if redis.call("SCARD", user_sessions_key) == 0 then
redis.call("SREM", users_index_key, user_id)
end
end
return "removed"
`
// redisPresenceUpdate 在 Redis 内部读取、校验、修改 JSON 并刷新 TTL。
// 这样 Touch/SetStatus 不会发生跨 gateway 读旧值再覆盖新绑定的竞态。
const redisPresenceUpdate = `
local presence_key = KEYS[1]
local session_user_key = KEYS[2]
local expected_user_id = ARGV[1]
local status = ARGV[2]
local metadata_json = ARGV[3]
local metadata_set = ARGV[4]
local updated_at = ARGV[5]
local ttl = tonumber(ARGV[6]) or 0
local user_id = redis.call("GET", session_user_key)
if not user_id or user_id == "" then
return "missing"
end
if expected_user_id ~= "" and user_id ~= expected_user_id then
return "mismatch"
end
local raw = redis.call("GET", presence_key)
if not raw or raw == "" then
return "missing"
end
local ok, item = pcall(cjson.decode, raw)
if not ok then
return "decode_error"
end
if item["user_id"] and item["user_id"] ~= user_id then
return "mismatch"
end
if status ~= "" then
item["status"] = status
end
if metadata_set == "1" then
if metadata_json == "" then
item["metadata"] = nil
else
local meta_ok, metadata = pcall(cjson.decode, metadata_json)
if not meta_ok then
return "decode_error"
end
item["metadata"] = metadata
end
end
item["updated_at"] = updated_at
local data = cjson.encode(item)
if ttl > 0 then
redis.call("SET", presence_key, data, "EX", ttl)
redis.call("SET", session_user_key, user_id, "EX", ttl)
else
redis.call("SET", presence_key, data)
redis.call("SET", session_user_key, user_id)
end
return "updated"
`
// redisSubScript 原子维护订阅和被订阅双索引。
const redisSubScript = `
local subscriptions_index_key = KEYS[1]
local subscribers_index_key = KEYS[2]
local subscriptions_key = KEYS[3]
local subscribers_key = KEYS[4]
local subscriber_id = ARGV[1]
local target_id = ARGV[2]
redis.call("SADD", subscriptions_index_key, subscriber_id)
redis.call("SADD", subscribers_index_key, target_id)
redis.call("SADD", subscriptions_key, target_id)
redis.call("SADD", subscribers_key, subscriber_id)
return "OK"
`
// redisUnsubScript 原子删除订阅和被订阅双索引。
const redisUnsubScript = `
local subscriptions_index_key = KEYS[1]
local subscribers_index_key = KEYS[2]
local subscriptions_key = KEYS[3]
local subscribers_key = KEYS[4]
local subscriber_id = ARGV[1]
local target_id = ARGV[2]
redis.call("SREM", subscriptions_key, target_id)
redis.call("SREM", subscribers_key, subscriber_id)
if redis.call("SCARD", subscriptions_key) == 0 then
redis.call("SREM", subscriptions_index_key, subscriber_id)
end
if redis.call("SCARD", subscribers_key) == 0 then
redis.call("SREM", subscribers_index_key, target_id)
end
return "OK"
`
// RedisOptions 控制 Redis 版 presence manager 的名称、执行器、key 前缀和 TTL。
type RedisOptions struct {
Name string
Executor db.RedisExecutor
KeyPrefix string
Now func() time.Time
TTL time.Duration
}
// RedisManager 使用 Redis 共享在线状态和订阅关系,适合多 gateway 协同。
type RedisManager struct {
initOnce sync.Once
name string
executor db.RedisExecutor
closer interface{ Close() error }
prefix string
now func() time.Time
ttl time.Duration
mu sync.Mutex
}
// NewRedis 创建 Redis 版 presence manager。
func NewRedis(options RedisOptions) *RedisManager {
name := strings.TrimSpace(options.Name)
if name == "" {
name = "redis-presence"
}
prefix := strings.Trim(strings.TrimSpace(options.KeyPrefix), ":")
if prefix == "" {
prefix = "longheng:presence"
}
now := options.Now
if now == nil {
now = time.Now
}
manager := &RedisManager{
name: name,
executor: options.Executor,
closer: redisCloser(options.Executor),
prefix: prefix,
now: now,
ttl: options.TTL,
}
manager.ensureReady()
return manager
}
func redisCloser(executor db.RedisExecutor) interface{ Close() error } {
closer, _ := executor.(interface{ Close() error })
return closer
}
func (m *RedisManager) ensureReady() {
m.initOnce.Do(func() {
m.name = strings.TrimSpace(m.name)
if m.name == "" {
m.name = "redis-presence"
}
m.prefix = strings.Trim(strings.TrimSpace(m.prefix), ":")
if m.prefix == "" {
m.prefix = "longheng:presence"
}
if m.now == nil {
m.now = time.Now
}
if m.closer == nil {
m.closer = redisCloser(m.executor)
}
})
}
func requireRedisManager(m *RedisManager) error {
if m == nil {
return ErrNilRedisExecutor
}
m.ensureReady()
return nil
}
// Close 关闭底层 Redis 连接;没有连接所有权时为空操作。
func (m *RedisManager) Close() error {
if m == nil {
return nil
}
m.ensureReady()
if m.closer == nil {
return nil
}
return m.closer.Close()
}
// Track 注册或替换某个 session 的 Redis 在线状态。
func (m *RedisManager) Track(ctx context.Context, item Presence) (Presence, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, err
}
if err := requireRedisManager(m); err != nil {
return Presence{}, err
}
item, err := normalizePresence(item, m.now().UTC())
if err != nil {
return Presence{}, err
}
data, err := json.Marshal(item)
if err != nil {
return Presence{}, err
}
// Track 使用 Lua 同时写 session、user 索引和反向索引,避免多 gateway 下索引半更新。
if _, err := m.do(ctx, "EVAL", redisPresenceTrack, 5,
m.presenceKey(item.SessionID),
m.sessionUserKey(item.SessionID),
m.sessionsIndexKey(),
m.usersIndexKey(),
m.userSessionsKey(item.UserID),
m.userSessionsPrefix(),
data,
item.UserID,
item.SessionID,
redisTTLSeconds(m.ttl),
); err != nil {
return Presence{}, err
}
return clonePresence(item), nil
}
// Untrack 移除指定 session 的 Redis 在线状态;userID 非空时必须匹配当前绑定。
func (m *RedisManager) Untrack(ctx context.Context, userID, sessionID string) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if err := requireRedisManager(m); err != nil {
return Presence{}, false, err
}
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
// 先读当前 session 再按 user 校验删除,避免断线事件误删已重绑的新 session。
m.mu.Lock()
defer m.mu.Unlock()
item, ok, err := m.session(ctx, sessionID)
if err != nil || !ok {
return Presence{}, false, err
}
if userID != "" && item.UserID != userID {
return Presence{}, false, nil
}
removed, err := m.removePresence(ctx, item)
if err != nil {
return Presence{}, false, err
}
if !removed {
return Presence{}, false, nil
}
return clonePresence(item), true, nil
}
// Touch 刷新指定 session 的 Redis 在线更新时间。
func (m *RedisManager) Touch(ctx context.Context, userID, sessionID string, at time.Time) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if err := requireRedisManager(m); err != nil {
return Presence{}, false, err
}
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
if at.IsZero() {
at = m.now()
}
// Touch 不在客户端侧改 JSON,而是交给 Lua 校验 user 后刷新 updated_at 和 TTL。
updated, err := m.updatePresence(ctx, userID, sessionID, "", nil, false, at.UTC())
if err != nil || !updated {
return Presence{}, false, err
}
item, ok, err := m.session(ctx, sessionID)
if err != nil || !ok {
return Presence{}, false, err
}
return clonePresence(item), true, nil
}
// SetStatus 更新指定 session 的 Redis 在线状态和元数据。
func (m *RedisManager) SetStatus(ctx context.Context, userID, sessionID, status string, metadata map[string]string) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if err := requireRedisManager(m); err != nil {
return Presence{}, false, err
}
userID = strings.TrimSpace(userID)
sessionID = strings.TrimSpace(sessionID)
status = strings.TrimSpace(status)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
if status == "" {
return Presence{}, false, ErrInvalidStatus
}
// metadata 传 nil 表示显式清空;调用方要保留旧 metadata 时不要调用 SetStatus。
updated, err := m.updatePresence(ctx, userID, sessionID, status, normalizeMetadata(metadata), true, m.now().UTC())
if err != nil || !updated {
return Presence{}, false, err
}
item, ok, err := m.session(ctx, sessionID)
if err != nil || !ok {
return Presence{}, false, err
}
return clonePresence(item), true, nil
}
// Presence 查询某个用户在 Redis 中的所有在线 session。
func (m *RedisManager) Presence(ctx context.Context, userID string) ([]Presence, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if err := requireRedisManager(m); err != nil {
return nil, err
}
userID = strings.TrimSpace(userID)
if userID == "" {
return nil, ErrInvalidUserID
}
value, err := m.do(ctx, "SMEMBERS", m.userSessionsKey(userID))
if err != nil {
return nil, err
}
sessionIDs, err := redisStrings(value)
if err != nil {
return nil, err
}
sort.Strings(sessionIDs)
out := make([]Presence, 0, len(sessionIDs))
for _, sessionID := range sessionIDs {
item, ok, err := m.session(ctx, sessionID)
if err != nil {
return nil, err
}
if !ok {
// 读到过期 session 时顺手清理用户反向索引,降低长期高 churn 下的脏索引。
_, _ = m.do(ctx, "SREM", m.userSessionsKey(userID), sessionID)
continue
}
out = append(out, clonePresence(item))
}
_ = m.cleanupUserIndex(ctx, userID)
sortPresences(out)
return out, nil
}
// Session 查询 Redis 中指定 session 的在线状态。
func (m *RedisManager) Session(ctx context.Context, sessionID string) (Presence, bool, error) {
if err := ctxErr(ctx); err != nil {
return Presence{}, false, err
}
if err := requireRedisManager(m); err != nil {
return Presence{}, false, err
}
return m.session(ctx, sessionID)
}
// Subscribe 在 Redis 中订阅目标用户在线状态变化。
func (m *RedisManager) Subscribe(ctx context.Context, subscriberID, targetID string) error {
if err := ctxErr(ctx); err != nil {
return err
}
if err := requireRedisManager(m); err != nil {
return err
}
// Subscribe 通过 Lua 同时维护 subscriber->targets 和 target->subscribers 两个索引,避免并发订阅留下半边索引。
subscriberID, targetID, err := normSubIDs(subscriberID, targetID)
if err != nil {
return err
}
_, err = m.do(ctx, "EVAL", redisSubScript, 4,
m.subIndexKey(),
m.subscribersIndexKey(),
m.subscriptionsKey(subscriberID),
m.subscribersKey(targetID),
subscriberID,
targetID,
)
return err
}
// Unsubscribe 在 Redis 中取消订阅目标用户在线状态变化。
func (m *RedisManager) Unsubscribe(ctx context.Context, subscriberID, targetID string) error {
if err := ctxErr(ctx); err != nil {
return err
}
if err := requireRedisManager(m); err != nil {
return err
}
// Unsubscribe 也用 Lua 做双索引删除;当集合为空时同步清 index,降低 stale cleanup 压力。
subscriberID, targetID, err := normSubIDs(subscriberID, targetID)
if err != nil {
return err
}
_, err = m.do(ctx, "EVAL", redisUnsubScript, 4,
m.subIndexKey(),
m.subscribersIndexKey(),
m.subscriptionsKey(subscriberID),
m.subscribersKey(targetID),
subscriberID,
targetID,
)
return err
}
// Subscribers 返回 Redis 中订阅目标用户的所有订阅者。
func (m *RedisManager) Subscribers(ctx context.Context, targetID string) ([]string, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if err := requireRedisManager(m); err != nil {
return nil, err
}
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, ErrInvalidUserID
}
value, err := m.do(ctx, "SMEMBERS", m.subscribersKey(targetID))
if err != nil {
return nil, err
}
out, err := redisStrings(value)
if err != nil {
return nil, err
}
sort.Strings(out)
return out, nil
}
// Subscriptions 返回 Redis 中订阅者关注的所有目标用户。
func (m *RedisManager) Subscriptions(ctx context.Context, subscriberID string) ([]string, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if err := requireRedisManager(m); err != nil {
return nil, err
}
subscriberID = strings.TrimSpace(subscriberID)
if subscriberID == "" {
return nil, ErrInvalidUserID
}
value, err := m.do(ctx, "SMEMBERS", m.subscriptionsKey(subscriberID))
if err != nil {
return nil, err
}
out, err := redisStrings(value)
if err != nil {
return nil, err
}
sort.Strings(out)
return out, nil
}
// Broadcast 将 Redis 中目标用户当前在线状态广播给所有订阅者。
func (m *RedisManager) Broadcast(ctx context.Context, targetID string, send SendFunc) error {
if send == nil {
return ErrNilSender
}
if err := ctxErr(ctx); err != nil {
return err
}
if err := requireRedisManager(m); err != nil {
return err
}
subscribers, err := m.Subscribers(ctx, targetID)
if err != nil {
return err
}
presences, err := m.Presence(ctx, targetID)
if err != nil {
return err
}
// Broadcast 只负责 fanout 当前在线状态,不提供 ack 或离线补偿。
// 需要可靠通知的业务应把事件先落 EventLog/Outbox。
for _, subscriberID := range subscribers {
if err := ctxErr(ctx); err != nil {
return err
}
if err := send(ctx, subscriberID, clonePresences(presences)); err != nil {
return err
}
}
return nil
}
// Snapshot 返回 Redis presence manager 当前快照;Redis 不可用时返回带名称的空快照。
func (m *RedisManager) Snapshot() Snapshot {
if m == nil {
return Snapshot{}
}
m.ensureReady()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
snapshot, err := m.snapshot(ctx)
if err != nil {
return Snapshot{Name: m.name}
}
return snapshot
}
func (m *RedisManager) session(ctx context.Context, sessionID string) (Presence, bool, error) {
if err := requireRedisManager(m); err != nil {
return Presence{}, false, err
}
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return Presence{}, false, ErrInvalidSessionID
}
value, err := m.do(ctx, "GET", m.presenceKey(sessionID))
if err != nil {
return Presence{}, false, err
}
data, ok, err := redisBytes(value)
if err != nil || !ok {
return Presence{}, ok, err
}
var item Presence
if err := json.Unmarshal(data, &item); err != nil {
return Presence{}, false, fmt.Errorf("decode presence: %w", err)
}
item.Metadata = normalizeMetadata(item.Metadata)
return item, true, nil
}
func (m *RedisManager) updatePresence(ctx context.Context, userID, sessionID, status string, metadata map[string]string, updateMetadata bool, updatedAt time.Time) (bool, error) {
if err := requireRedisManager(m); err != nil {
return false, err
}
metadataJSON := ""
if metadata != nil {
data, err := json.Marshal(metadata)
if err != nil {
return false, err
}
metadataJSON = string(data)
}
statusText, err := redisString(m.do(ctx, "EVAL", redisPresenceUpdate, 2,
m.presenceKey(sessionID),
m.sessionUserKey(sessionID),
userID,
status,
metadataJSON,
boolRedisArg(updateMetadata),
updatedAt.Format(time.RFC3339Nano),
redisTTLSeconds(m.ttl),
))
if err != nil {
return false, err
}
switch statusText {
case "updated":
return true, nil
case "missing", "mismatch":
return false, nil
case "decode_error":
return false, fmt.Errorf("decode presence")
default:
return false, fmt.Errorf("unexpected redis presence update status %q", statusText)
}
}
func boolRedisArg(value bool) string {
if value {
return "1"
}
return "0"
}
func (m *RedisManager) removePresence(ctx context.Context, item Presence) (bool, error) {
if err := requireRedisManager(m); err != nil {
return false, err
}
status, err := redisString(m.do(ctx, "EVAL", redisPresenceRemove, 4,
m.presenceKey(item.SessionID),
m.sessionUserKey(item.SessionID),
m.sessionsIndexKey(),
m.usersIndexKey(),
m.userSessionsPrefix(),
item.SessionID,
item.UserID,
))
if err != nil {
return false, err
}
return status == "removed", nil
}
func (m *RedisManager) snapshot(ctx context.Context) (Snapshot, error) {
if err := requireRedisManager(m); err != nil {
return Snapshot{}, err
}
sessionIDs, err := m.redisSet(ctx, m.sessionsIndexKey())
if err != nil {
return Snapshot{}, err
}
presences := make([]Presence, 0, len(sessionIDs))
for _, sessionID := range sessionIDs {
item, ok, err := m.session(ctx, sessionID)
if err != nil {
return Snapshot{}, err
}
if !ok {
// Snapshot 是管理面读路径,可以顺带清理过期 session 索引。
_, _ = m.do(ctx, "SREM", m.sessionsIndexKey(), sessionID)
continue
}
presences = append(presences, item)
}
sortPresences(presences)
userIDs, err := m.redisSet(ctx, m.usersIndexKey())
if err != nil {
return Snapshot{}, err
}
userCount := 0
for _, userID := range userIDs {
presences, err := m.Presence(ctx, userID)
if err != nil {
return Snapshot{}, err
}
if len(presences) > 0 {
userCount++
}
}
subscriberIDs, err := m.redisSet(ctx, m.subIndexKey())
if err != nil {
return Snapshot{}, err
}
subscriptions := make([]Subscription, 0)
for _, subscriberID := range subscriberIDs {
targets, err := m.Subscriptions(ctx, subscriberID)
if err != nil {
return Snapshot{}, err
}
if len(targets) == 0 {
// 订阅者没有任何目标时清掉全局索引,避免长期压测后列表膨胀。
_, _ = m.do(ctx, "SREM", m.subIndexKey(), subscriberID)
continue
}
for _, targetID := range targets {
subscriptions = append(subscriptions, Subscription{SubscriberID: subscriberID, TargetID: targetID})
}
}
sortSubscriptions(subscriptions)
return Snapshot{
Name: m.name,
PresenceCount: len(presences),
UserCount: userCount,
SubscriptionCount: len(subscriptions),
Presences: presences,
Subscriptions: subscriptions,
}, nil
}
func (m *RedisManager) cleanupUserIndex(ctx context.Context, userID string) error {
if err := requireRedisManager(m); err != nil {
return err
}
count, err := m.redisInt(ctx, "SCARD", m.userSessionsKey(userID))
if err != nil {
return err
}
if count == 0 {
_, err = m.do(ctx, "SREM", m.usersIndexKey(), userID)
}
return err
}
func (m *RedisManager) redisSet(ctx context.Context, key string) ([]string, error) {
if err := requireRedisManager(m); err != nil {
return nil, err
}
value, err := m.do(ctx, "SMEMBERS", key)
if err != nil {
return nil, err
}
out, err := redisStrings(value)
if err != nil {
return nil, err
}
sort.Strings(out)
return out, nil
}
func (m *RedisManager) redisInt(ctx context.Context, command string, args ...any) (int64, error) {
if err := requireRedisManager(m); err != nil {
return 0, err
}
value, err := m.do(ctx, command, args...)
if err != nil {
return 0, err
}
return redisInt64(value)
}
func (m *RedisManager) do(ctx context.Context, command string, args ...any) (any, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if err := requireRedisManager(m); err != nil {
return nil, err
}
if m.executor == nil {
return nil, ErrNilRedisExecutor
}
return m.executor.Do(ctx, command, args...)
}
func (m *RedisManager) presenceKey(sessionID string) string {
return m.prefix + ":session:" + sessionID
}
func (m *RedisManager) sessionUserKey(sessionID string) string {
return m.prefix + ":session_user:" + sessionID
}
func (m *RedisManager) userSessionsKey(userID string) string {
return m.prefix + ":user_sessions:" + userID
}
func (m *RedisManager) userSessionsPrefix() string {
return m.prefix + ":user_sessions:"
}
func (m *RedisManager) sessionsIndexKey() string {
return m.prefix + ":sessions"
}
func (m *RedisManager) usersIndexKey() string {
return m.prefix + ":users"
}
func (m *RedisManager) subscriptionsKey(subscriberID string) string {
return m.prefix + ":subscriptions:" + subscriberID
}
func (m *RedisManager) subscribersKey(targetID string) string {
return m.prefix + ":subscribers:" + targetID
}
func (m *RedisManager) subIndexKey() string {
return m.prefix + ":subscriptions"
}
func (m *RedisManager) subscribersIndexKey() string {
return m.prefix + ":subscribers"
}
func redisTTLSeconds(ttl time.Duration) int64 {
if ttl <= 0 {
return 0
}
seconds := int64(ttl / time.Second)
if seconds <= 0 {
return 1
}
return seconds
}
func redisBytes(value any) ([]byte, bool, error) {
switch v := value.(type) {
case nil:
return nil, false, nil
case []byte:
return append([]byte(nil), v...), true, nil
case string:
return []byte(v), true, nil
default:
return nil, false, fmt.Errorf("unexpected redis bulk type %T", value)
}
}
func redisString(value any, err error) (string, error) {
if err != nil {
return "", err
}
switch v := value.(type) {
case nil:
return "", nil
case []byte:
return string(v), nil
case string:
return v, nil
default:
return "", fmt.Errorf("unexpected redis string type %T", value)
}
}
func redisStrings(value any) ([]string, error) {
switch v := value.(type) {
case nil:
return nil, nil
case []string:
return append([]string(nil), v...), nil
case [][]byte:
out := make([]string, len(v))
for i := range v {
out[i] = string(v[i])
}
return out, nil
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
switch x := item.(type) {
case []byte:
out = append(out, string(x))
case string:
out = append(out, x)
default:
return nil, fmt.Errorf("unexpected redis string item %T", item)
}
}
return out, nil
default:
return nil, fmt.Errorf("unexpected redis string slice type %T", value)
}
}
func redisInt64(value any) (int64, error) {
switch v := value.(type) {
case int:
return int64(v), nil
case int64:
return v, nil
case uint64:
const maxInt64 = uint64(1<<63 - 1)
if v > maxInt64 {
return 0, fmt.Errorf("redis integer overflows int64: %d", v)
}
return int64(v), nil
case []byte:
return strconv.ParseInt(string(v), 10, 64)
case string:
return strconv.ParseInt(v, 10, 64)
default:
return 0, fmt.Errorf("unexpected redis integer type %T", value)
}
}