diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4063493 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.idea/* +rabbitmq/开发指南.md +ragflow/agent文档.md +ragflow/README_GLOBAL.md +redis/stream使用示例.md +ragflow/client_backup.go.bak +ragflow/为什么不能使用gclient.md +ragflow/agent文档.md +/.idea/MarsCodeWorkspaceAppSettings.xml +consul/配置中心方案.md +consul/配置中心方案.md diff --git a/beans/beans.go b/beans/beans.go new file mode 100644 index 0000000..31806bd --- /dev/null +++ b/beans/beans.go @@ -0,0 +1,54 @@ +package beans + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ResponseEmpty struct { +} + +type Page struct { + PageNum int64 `p:"pageNum"` //当前页码 + PageSize int64 `p:"pageSize"` //每页数 + Total int64 //总页数 +} + +type OrderEnum string + +const ( + Asc OrderEnum = "asc" // 正序 + Desc OrderEnum = "desc" // 倒序 +) + +type OrderBy struct { + Field string `p:"field"` //排序字段 + Order OrderEnum `p:"order"` //排序方式 +} +type MongoBaseDO struct { + Id *bson.ObjectID `bson:"_id,omitempty" json:"id"` // MongoDB 默认 ID + Creator interface{} `bson:"creator,omitempty" json:"creator"` + CreatedAt *time.Time `bson:"createdAt,omitempty" json:"createdAt"` + Updater interface{} `bson:"updater,omitempty" json:"updater"` + UpdatedAt *time.Time `bson:"updatedAt,omitempty" json:"updatedAt"` + TenantId interface{} `bson:"tenantId" json:"tenantId" default:"1"` // 租户ID + IsDeleted bool `bson:"isDeleted" json:"isDeleted" default:"false"` +} + +// SQLBaseDO SQL数据库基础实体 +type SQLBaseDO struct { + Id uint64 `json:"id"` // 主键ID + Creator string `json:"creator"` // 创建人 + CreatedAt *time.Time `json:"createdAt"` // 创建时间 + Updater string `json:"updater"` // 更新人 + UpdatedAt *time.Time `json:"updatedAt"` // 更新时间 + TenantId string `json:"tenantId"` // 租户ID + IsDeleted bool `json:"isDeleted"` // 是否删除 +} + +type User struct { + UserId interface{} `bson:"userId" json:"userId"` // 用户ID + UserName interface{} `bson:"userName" json:"userName"` // 用户名 + TenantId interface{} `bson:"tenantId" json:"tenantId"` // 租户ID +} diff --git a/beans/module_tenant.go b/beans/module_tenant.go new file mode 100644 index 0000000..4b53f8b --- /dev/null +++ b/beans/module_tenant.go @@ -0,0 +1,81 @@ +package beans + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// ModuleAssetId 模块资产ID映射(key-value结构) +// Key: 服务名,Value: 资产ID +var ModuleAssetId = map[string]string{ + "assets": "696b4acd1be1c8b76c4b4c15", // 资产模块 + "cid": "696f423705e496ba4ccbe665", // 广告模块 + "customerService": "696f421205e496ba4ccbe662", // AI客服模块 +} + +// 模块类型(值从ModuleAssetId map获取) +var ( + TenantModuleAssets = ModuleAssetId["assets"] // 资产模块 + TenantModuleAd = ModuleAssetId["cid"] // 广告模块 + TenantModuleAICs = ModuleAssetId["customerService"] // AI客服模块 +) + +type TenantModuleType string + +const ( + TenantModuleTypePlatform TenantModuleType = "platform" + TenantModuleTypePrivate TenantModuleType = "private" + TenantModuleTypeSupplier TenantModuleType = "supplier" + TenantModuleTypeSmallShop TenantModuleType = "small_shop" +) + +// TenantModuleTypeKV 租户类型 +type TenantModuleTypeKV struct { + Key string + Value string +} + +// TenantModuleTypesAssets 资产模块租户类型 +var TenantModuleTypesAssets = []TenantModuleTypeKV{ + {Key: string(TenantModuleTypePrivate), Value: "私域租户"}, + {Key: string(TenantModuleTypeSupplier), Value: "供应商"}, + {Key: string(TenantModuleTypeSmallShop), Value: "电商小店"}, +} + +// TenantModuleTypesAd 广告模块租户类型(待定) +var TenantModuleTypesAd []TenantModuleTypeKV + +// TenantModuleTypesAICs AI客服模块租户类型(待定) +var TenantModuleTypesAICs []TenantModuleTypeKV + +// GetTenantModuleTypes 获取模块的租户类型列表 +func GetTenantModuleTypes(module string) []TenantModuleTypeKV { + switch module { + case TenantModuleAssets: + return TenantModuleTypesAssets + case TenantModuleAd: + return TenantModuleTypesAd + case TenantModuleAICs: + return TenantModuleTypesAICs + default: + return []TenantModuleTypeKV{} + } +} + +type ModuleTenantCheckReq struct { + ModuleKey string `p:"moduleKey" v:"required#模块Key不能为空"` + TenantId uint64 `p:"tenantId" v:"required#租户ID不能为空"` +} + +// ModuleTenantCheckRes 调用admin-go设置模块租户关系的响应 +type ModuleTenantCheckRes struct { + Status bool `json:"status"` + CertificationStatus bool `json:"certificationStatus"` + Message string `json:"message"` // 状态描述 +} + +// ModuleTenant 模块租户关系实体(引用自admin-go) +type ModuleTenant struct { + ExpireAt *gtime.Time `json:"expireAt" description:"到期时间"` + TenantModuleType TenantModuleType `json:"tenantModuleType" description:"租户模块类型"` + CertificationStatus int `json:"certificationStatus" description:"认证状态"` +} diff --git a/consul/consul.go b/consul/consul.go new file mode 100644 index 0000000..b5e79cf --- /dev/null +++ b/consul/consul.go @@ -0,0 +1,95 @@ +package consul + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + + "github.com/gogf/gf/contrib/registry/consul/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gsel" + "github.com/gogf/gf/v2/net/gsvc" + "github.com/gogf/gf/v2/util/grand" +) + +var initOnce sync.Once + +// Init 初始化 Consul 注册中心(延迟初始化,首次调用时执行) +func Init() { + initOnce.Do(func() { + consulAddr := g.Cfg().MustGet(context.Background(), "consul.address").String() + if consulAddr == "" { + g.Log().Warning(context.Background(), "⚠️ Consul 配置未找到,跳过初始化") + return + } + registry, err := consul.New(consul.WithAddress(consulAddr)) + if err != nil { + g.Log().Errorf(context.Background(), "Consul 初始化失败: %v", err) + return + } + gsvc.SetRegistry(registry) + gsel.SetBuilder(gsel.NewBuilderRoundRobin()) + g.Log().Infof(context.Background(), "✅ Consul 初始化成功: %s", consulAddr) + }) +} + +func init() { + // 默认自动初始化(保持向后兼容) + Init() +} +func getLocalIP() (string, error) { + // 获取本机所有网络接口 + addrs, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + // 检查是否是IP地址 + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + // 返回第一个非回环的IPv4地址 + return ipNet.IP.String(), nil + } + } + } + + return "", fmt.Errorf("无法找到本地IP地址") +} +func getInstanceAddrByIp(ctx context.Context, ip string, services []gsvc.Service) (addr string) { + for _, s := range services { + if s.GetEndpoints()[0].Host() == ip { + addr = s.GetEndpoints()[0].String() + return + } + } + return +} +func GetInstanceAddr(ctx context.Context, name string) (addr string, err error) { + watch, err := gsvc.GetRegistry().Watch(ctx, name) + service, err := watch.Proceed() + if err != nil || service == nil { + err = errors.New("获取customerService服务实例失败!") + return + } + //优先使用客户端IP获取实例(前后端在同一台机器调试) + addr = getInstanceAddrByIp(ctx, g.RequestFromCtx(ctx).GetClientIp(), service) + if !g.IsEmpty(addr) { + return + } + //优先使用gateway同IP的服务实例(前后端不同机器调试) + addr, err = getLocalIP() + if err != nil { + return + } + addr = getInstanceAddrByIp(ctx, addr, service) + if !g.IsEmpty(addr) { + return + } + //随机获取一个服务实例 + maxService := grand.N(0, len(service)-1) + addr = service[maxService].GetEndpoints()[0].String() + return +} diff --git a/db/mongo/connection.go b/db/mongo/connection.go new file mode 100644 index 0000000..41cd249 --- /dev/null +++ b/db/mongo/connection.go @@ -0,0 +1,443 @@ +// ============================================================================= +// MongoDB 数据源连接管理 +// 负责数据源的连接、重连、健康检查和优雅关闭 +// ============================================================================= + +package mongo + +import ( + "context" + "fmt" + "net/url" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "gitea.com/red-future/common/log/consts" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/grpool" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// ============================================================================= +// 数据源配置结构 +// ============================================================================= + +type DataSourceConfig struct { + Name string `json:"name"` + Address string `json:"address"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + MaxPoolSize int32 `json:"maxPoolSize"` + MinPoolSize int32 `json:"minPoolSize"` + ConnectTimeout time.Duration `json:"connectTimeout"` +} + +// ============================================================================= +// 单个数据源接口 +// ============================================================================= + +type DataSource interface { + Name() string + Database() *mongo.Database + Client() *mongo.Client + IsConnected() bool + Connect(ctx context.Context) error + Reconnect(ctx context.Context) error + Close(ctx context.Context) error +} + +// ============================================================================= +// 数据源实现 +// ============================================================================= + +type BaseDataSource struct { + config *DataSourceConfig + client *mongo.Client + database *mongo.Database + isConnected bool + mu sync.RWMutex + lastError error + lastErrorTime time.Time +} + +func NewBaseDataSource(config *DataSourceConfig) *BaseDataSource { + return &BaseDataSource{ + config: config, + isConnected: false, + } +} + +func (d *BaseDataSource) Name() string { + return d.config.Name +} + +func (d *BaseDataSource) Database() *mongo.Database { + d.mu.RLock() + defer d.mu.RUnlock() + return d.database +} + +func (d *BaseDataSource) Client() *mongo.Client { + d.mu.RLock() + defer d.mu.RUnlock() + return d.client +} + +func (d *BaseDataSource) IsConnected() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.isConnected && d.client != nil +} + +func (d *BaseDataSource) Connect(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.client != nil { + d.client.Disconnect(ctx) + } + + // 解析数据库名 + dbName := d.config.Database + if strings.Contains(dbName, "?") { + dbName = gstr.SubStr(dbName, 0, strings.Index(dbName, "?")) + } + + // 构建连接URI + connectionURI := fmt.Sprintf("mongodb://%s", d.config.Address) + + // 如果配置了用户名和密码,添加到URI中 + if d.config.Username != "" { + // URL编码用户名和密码,正确处理特殊字符 + encodedUsername := url.QueryEscape(d.config.Username) + encodedPassword := url.QueryEscape(d.config.Password) + + // 构建认证信息 + authInfo := fmt.Sprintf("%s:%s@", encodedUsername, encodedPassword) + + // 将认证信息插入到URI中 + connectionURI = fmt.Sprintf("mongodb://%s%s", authInfo, d.config.Address) + } + + // 构建连接选项 + opt := options.Client(). + ApplyURI(connectionURI). + SetMaxPoolSize(uint64(d.config.MaxPoolSize)). + SetMinPoolSize(uint64(d.config.MinPoolSize)). + SetConnectTimeout(d.config.ConnectTimeout). + SetMaxConnecting(10). + SetServerSelectionTimeout(10 * time.Second). + SetHeartbeatInterval(10 * time.Second). + SetMaxConnIdleTime(60 * time.Second). + SetRetryWrites(true). + SetRetryReads(true).SetMonitor(commandMonitor()) + + var err error + d.client, err = mongo.Connect(opt) + if err != nil { + d.isConnected = false + d.lastError = err + d.lastErrorTime = time.Now() + return fmt.Errorf("datasource [%s] connection failed: %w", d.config.Name, err) + } + + // 测试连接 + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err = d.client.Ping(pingCtx, nil); err != nil { + d.isConnected = false + d.lastError = err + d.lastErrorTime = time.Now() + return fmt.Errorf("datasource [%s] ping failed: %w", d.config.Name, err) + } + + d.database = d.client.Database(dbName) + d.isConnected = true + d.lastError = nil + glog.Infof(ctx, "✅ datasource [%s] connected successfully", d.config.Name) + return nil +} + +func (d *BaseDataSource) Reconnect(ctx context.Context) error { + glog.Infof(ctx, "🔄 reconnecting datasource [%s]", d.config.Name) + return d.Connect(ctx) +} + +func (d *BaseDataSource) Close(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.client != nil { + disconnectCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := d.client.Disconnect(disconnectCtx); err != nil { + return fmt.Errorf("datasource [%s] close failed: %w", d.config.Name, err) + } + } + + d.isConnected = false + glog.Infof(ctx, "datasource [%s] closed", d.config.Name) + return nil +} + +// ============================================================================= +// 多数据源管理器 +// ============================================================================= + +type DataSourceManager struct { + sources map[string]DataSource + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + started bool + maxRetries int +} + +var ( + globalManager *DataSourceManager + managerOnce sync.Once +) + +// GetManager 获取全局管理器 +func GetManager() *DataSourceManager { + managerOnce.Do(func() { + ctx, cancel := context.WithCancel(context.Background()) + globalManager = &DataSourceManager{ + sources: make(map[string]DataSource), + ctx: ctx, + cancel: cancel, + started: false, + maxRetries: 3, + } + }) + return globalManager +} + +// RegisterDataSource 注册数据源 +func (m *DataSourceManager) RegisterDataSource(config *DataSourceConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.sources[config.Name]; exists { + return fmt.Errorf("datasource [%s] already exists", config.Name) + } + + source := NewBaseDataSource(config) + m.sources[config.Name] = source + return nil +} + +// GetDataSource 获取数据源 +func (m *DataSourceManager) GetDataSource(name string) (DataSource, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + source, exists := m.sources[name] + if !exists { + return nil, fmt.Errorf("datasource [%s] not found", name) + } + return source, nil +} + +// GetAllDataSourceNames 获取所有数据源名称 +func (m *DataSourceManager) GetAllDataSourceNames() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + names := make([]string, 0, len(m.sources)) + for name := range m.sources { + names = append(names, name) + } + return names +} + +// init 初始化多数据源 +func init() { + logPool = grpool.New(1) + serverName = g.Cfg().MustGet(context.TODO(), "server.name").String() + LogRedisKey = fmt.Sprintf(consts.StreamKey, serverName) + + ctx := context.Background() + + // 从配置初始化多数据源 + if err := manager.InitializeFromConfig(ctx); err != nil { + glog.Errorf(ctx, "❌ Failed to initialize MongoDB datasources: %v", err) + } else { + glog.Infof(ctx, "✅ MongoDB datasources initialized: %v", manager.GetAllDataSourceNames()) + } + + // 启动健康检查 + manager.StartHealthCheck() + + // 设置优雅关闭 + setupGracefulShutdown() +} + +// InitializeFromConfig 从配置初始化数据源 +// 动态读取 config.yml 中 mongo 下的所有配置项 +func (m *DataSourceManager) InitializeFromConfig(ctx context.Context) error { + var firstErr error + + // 获取 mongo 配置下的所有子键 + mongoConfig := g.Cfg().MustGet(ctx, "mongo") + if mongoConfig.IsNil() { + glog.Warningf(ctx, "no mongo configuration found in config.yml") + return nil + } + + // 将配置转换为 map + configMap := mongoConfig.Map() + if configMap == nil { + glog.Warningf(ctx, "mongo configuration is not a map") + return nil + } + + // 遍历所有 mongo 子配置 + for name, subConfig := range configMap { + // 跳过非对象类型的配置 + subMap, ok := subConfig.(map[string]interface{}) + if !ok { + continue + } + + // 检查是否有 address 配置 + address, hasAddress := subMap["address"] + if !hasAddress || gconv.String(address) == "" { + continue + } + + // 构建数据源配置 + config := &DataSourceConfig{ + Name: name, + Address: gconv.String(address), + Database: gconv.String(subMap["database"]), + Username: gconv.String(subMap["username"]), + Password: gconv.String(subMap["password"]), + MaxPoolSize: int32(gconv.Int(subMap["maxPoolSize"])), + MinPoolSize: int32(gconv.Int(subMap["minPoolSize"])), + ConnectTimeout: gconv.Duration(subMap["connectTimeout"]), + } + + // 设置默认值 + if config.MaxPoolSize == 0 { + config.MaxPoolSize = 100 + } + if config.MinPoolSize == 0 { + config.MinPoolSize = 10 + } + if config.ConnectTimeout == 0 { + config.ConnectTimeout = 10 * time.Second + } + + // 注册数据源 + if err := m.RegisterDataSource(config); err != nil { + glog.Errorf(ctx, "failed to register datasource [%s]: %v", name, err) + if firstErr == nil { + firstErr = err + } + continue + } + + // 连接数据源 + source, _ := m.GetDataSource(name) + if err := source.Connect(ctx); err != nil { + glog.Errorf(ctx, "failed to initialize datasource [%s]: %v", name, err) + if firstErr == nil { + firstErr = err + } + } + } + + return firstErr +} + +// StartHealthCheck 启动健康检查 +func (m *DataSourceManager) StartHealthCheck() { + if m.started { + return + } + m.started = true + go m.healthCheckLoop() +} + +// healthCheckLoop 健康检查循环 +func (m *DataSourceManager) healthCheckLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + m.checkAndReconnect() + } + } +} + +// checkAndReconnect 检查并重新连接 +func (m *DataSourceManager) checkAndReconnect() { + m.mu.RLock() + defer m.mu.RUnlock() + + for name, source := range m.sources { + if !source.IsConnected() { + glog.Warningf(context.Background(), "datasource [%s] disconnected, attempting reconnect", name) + + reconnectCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := source.Reconnect(reconnectCtx); err != nil { + glog.Errorf(reconnectCtx, "datasource [%s] reconnect failed: %v", name, err) + } else { + glog.Infof(reconnectCtx, "✅ datasource [%s] reconnected successfully", name) + } + } + } +} + +// CloseAll 关闭所有数据源 +func (m *DataSourceManager) CloseAll(ctx context.Context) error { + m.cancel() + + m.mu.RLock() + defer m.mu.RUnlock() + + var lastErr error + for name, source := range m.sources { + if err := source.Close(ctx); err != nil { + glog.Errorf(ctx, "failed to close datasource [%s]: %v", name, err) + lastErr = err + } + } + return lastErr +} + +// setupGracefulShutdown 设置优雅关闭 +func setupGracefulShutdown() { + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + glog.Info(ctx, "🔄 Shutting down MongoDB connections...") + if err := manager.CloseAll(ctx); err != nil { + glog.Errorf(ctx, "❌ Failed to close MongoDB connections: %v", err) + } else { + glog.Info(ctx, "✅ MongoDB connections closed successfully") + } + }() +} diff --git a/db/mongo/mongo.go b/db/mongo/mongo.go new file mode 100644 index 0000000..0c3d360 --- /dev/null +++ b/db/mongo/mongo.go @@ -0,0 +1,667 @@ +// ============================================================================= +// MongoDB 业务操作封装 +// 提供向后兼容的CRUD操作方法,支持多数据源 +// ============================================================================= + +package mongo + +import ( + "context" + "errors" + "fmt" + "time" + + "gitea.com/red-future/common/log/consts" + "go.mongodb.org/mongo-driver/v2/event" + + "gitea.com/red-future/common/beans" + "gitea.com/red-future/common/log/model/entity" + "gitea.com/red-future/common/redis" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/grpool" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// ============================================================================= +// 向后兼容的MongoDB结构体 +// ============================================================================= + +type mongoDB struct { + noCache bool + dataSource string // 数据源名称,默认为 "default" + noTenantId bool // 是否跳过租户过滤 +} + +func DB(cache ...bool) *mongoDB { + return &mongoDB{ + noCache: false, + dataSource: "default", + noTenantId: false, + } +} + +// commandMonitor 命令监控器 +func commandMonitor() *event.CommandMonitor { + return &event.CommandMonitor{ + // 命令执行前触发 + Started: func(ctx context.Context, evt *event.CommandStartedEvent) { + // 1. 安全获取集合名:先判断字段是否存在,避免空值调用 + collectionName := "无" // 默认值 + collectionVal := evt.Command.Lookup("collection") + if !g.IsEmpty(collectionVal) { // 先检查是否为nil + collectionName = collectionVal.StringValue() + } + + // 2. 打印标准化日志(避免字段缺失导致的格式错乱) + fmt.Printf("[%s] 开始执行命令 | 数据库: %s | 集合: %s | 命令: %+v\n", + time.Now().Format("2006-01-02 15:04:05"), + evt.DatabaseName, + collectionName, + evt.Command, + ) + + // 也可以在这里添加:参数校验、权限检查、链路追踪埋点等 + // 例如:将开始时间存入ctx,供后续结束时计算耗时 + ctx = context.WithValue(ctx, "cmd_start_time", time.Now()) + }, + + // 命令执行成功后触发 + Succeeded: func(ctx context.Context, evt *event.CommandSucceededEvent) { + // 从ctx中获取开始时间,计算执行耗时 + startTime, ok := ctx.Value("cmd_start_time").(time.Time) + if ok { + elapsed := time.Since(startTime) + fmt.Printf("[%s] 命令执行成功 | 耗时: %s | 结果: %+v\n", + time.Now().Format("2006-01-02 15:04:05"), + elapsed, + evt.Reply, + ) + } + + // 也可以在这里添加:日志入库、性能指标上报、结果校验等 + }, + + // 命令执行失败后触发 + Failed: func(ctx context.Context, evt *event.CommandFailedEvent) { + fmt.Printf("[%s] 命令执行失败 | 错误: %s | 耗时: %s\n", + time.Now().Format("2006-01-02 15:04:05"), + evt.Failure, + evt.Duration, + ) + + // 也可以在这里添加:错误告警、重试逻辑、异常日志记录等 + }, + } +} + +// WithDataSource 指定使用的数据源 +func (m *mongoDB) WithDataSource(name string) *mongoDB { + m.dataSource = name + return m +} + +// NoCache 不使用缓存 +func (m *mongoDB) NoCache() *mongoDB { + m.noCache = true + return m +} + +// NoTenantId 不使用租户过滤 +func (m *mongoDB) NoTenantId() *mongoDB { + m.noTenantId = true + return m +} + +// ============================================================================= +// 向后兼容的全局变量和方法 +// ============================================================================= + +var ( + manager = GetManager() + logPool *grpool.Pool + serverName string + LogRedisKey string +) + +// FieldInfo 定义字段信息结构体 +type FieldInfo struct { + FieldName string + FieldValue interface{} +} + +const PageSize = 20 + +// GetDB 获取默认数据源的数据库实例(向后兼容) +func GetDB() *mongo.Database { + source, err := manager.GetDataSource("default") + if err != nil { + return nil + } + return source.Database() +} + +// ============================================================================= +// MongoDB 操作方法(支持多数据源) +// ============================================================================= + +// getDataSource 获取当前使用的数据源 +func (m *mongoDB) getDataSource() (DataSource, error) { + if m.dataSource == "" { + m.dataSource = "default" + } + return manager.GetDataSource(m.dataSource) +} + +// Count 查询总数 +func (m *mongoDB) Count(ctx context.Context, filter bson.M, collection string) (count int64, err error) { + source, err := m.getDataSource() + if err != nil { + return 0, err + } + db := source.Database() + + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + filter["isDeleted"] = false + delete(filter, "tenantId") + filterKey := fmt.Sprintf("%+v", filter) + redisKey := fmt.Sprintf(redis.Count, user.TenantId, collection, filterKey) + if !m.noCache { + var resultStr *gvar.Var + resultStr, err = redis.RedisClient().Get(ctx, redisKey) + if err != nil { + return + } + if !g.IsEmpty(resultStr) { + count = gconv.Int64(resultStr) + return + } + } + // 如果没有调用 noTenantId,则添加 tenantId 过滤 + if !m.noTenantId && !g.IsEmpty(user.TenantId) { + filter["tenantId"] = user.TenantId + } + count, err = db.Collection(collection).CountDocuments(ctx, filter) + if !m.noCache { + err = redis.RedisClient().SetEX(ctx, redisKey, count, int64(time.Hour)) + if err != nil { + return + } + } + return +} + +// Find 查询多条记录 +func (m *mongoDB) Find(ctx context.Context, filter bson.M, result interface{}, collection string, page *beans.Page, orderBy []beans.OrderBy) (total int64, err error) { + source, err := m.getDataSource() + if err != nil { + return 0, err + } + db := source.Database() + + if err = utils.ValidStructPtr(result); err != nil { + return + } + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + if g.IsEmpty(filter["isDeleted"]) { + filter["isDeleted"] = false + } + filterKey := fmt.Sprintf("%+v", filter) + optionsKey := fmt.Sprintf("%+v%+v", page, orderBy) + redisKey := fmt.Sprintf(redis.List, user.TenantId, collection, filterKey, optionsKey) + if !m.noCache { + var resultStr *gvar.Var + resultStr, err = redis.RedisClient().Get(ctx, redisKey) + if err != nil { + return + } + if !resultStr.IsEmpty() { + if err = resultStr.Structs(result); err != nil { + return + } + total = int64(len(resultStr.Array())) + return + } + } + // 如果没有调用 noTenantId,则添加 tenantId 过滤 + if !m.noTenantId && !g.IsEmpty(user.TenantId) { + filter["tenantId"] = user.TenantId + } + limit := int64(PageSize) + skip := int64(0) + if page != nil && !g.IsEmpty(page.PageNum) && !g.IsEmpty(page.PageSize) { + limit = page.PageSize + if limit == -1 { + skip = 0 + } else { + skip = (page.PageNum - 1) * limit + } + } + opt := options.Find().SetSkip(skip) + if limit != -1 { + opt.SetLimit(limit) + total, err = m.Count(ctx, filter, collection) + if err != nil || total == 0 { + return + } + } + if orderBy == nil { + opt.SetSort(bson.M{"createdAt": -1}) + } else { + orderBson := bson.D{} + for _, v := range orderBy { + if v.Order == beans.Asc { + orderBson = append(orderBson, bson.E{Key: v.Field, Value: 1}) + } else { + orderBson = append(orderBson, bson.E{Key: v.Field, Value: -1}) + } + } + opt.SetSort(orderBson) + } + + cur, err := db.Collection(collection).Find(ctx, filter, opt) + if err != nil { + return + } + if limit == -1 { + total = int64(cur.RemainingBatchLength()) + } + defer cur.Close(ctx) + if err = cur.All(ctx, result); err != nil { + return + } + if !m.noCache { + err = redis.RedisClient().SetEX(ctx, redisKey, result, int64(time.Hour)) + if err != nil { + return + } + } + return +} + +// FindOne 查询1条记录 +func (m *mongoDB) FindOne(ctx context.Context, filter bson.M, result interface{}, collection string, opts ...options.Lister[options.FindOneOptions]) (err error) { + source, err := m.getDataSource() + if err != nil { + return err + } + db := source.Database() + + if len(filter) == 0 { + err = gerror.New("缺少查询条件") + return + } + if err = utils.ValidStructPtr(result); err != nil { + return + } + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + filter["isDeleted"] = false + filterKey := fmt.Sprintf("%+v", filter) + redisKey := fmt.Sprintf(redis.One, user.TenantId, collection, filterKey) + if !m.noCache { + var resultStr *gvar.Var + resultStr, err = redis.RedisClient().Get(ctx, redisKey) + if err != nil { + return + } + if !g.IsEmpty(resultStr) { + err = gconv.Scan(resultStr, result) + if err != nil { + return err + } + return + } + } + // 如果没有调用 noTenantId,则添加 tenantId 过滤 + if !m.noTenantId && !g.IsEmpty(user.TenantId) { + filter["tenantId"] = user.TenantId + } + cur := db.Collection(collection).FindOne(ctx, filter, opts...) + err = cur.Decode(result) + if errors.Is(err, mongo.ErrNoDocuments) { + err = nil + } + if !m.noCache { + err = redis.RedisClient().SetEX(ctx, redisKey, result, int64(time.Hour)) + if err != nil { + return err + } + } + return +} + +// getDeletedData 获取要删除的数据 +func (m *mongoDB) getDeletedData(ctx context.Context, filter bson.M, collection string) (deletedIDs []bson.ObjectID, deletedData []bson.M, err error) { + // 查询要删除的数据 + _, err = m.Find(ctx, filter, &deletedData, collection, nil, nil) + // 从查询结果中获取 _id + for _, doc := range deletedData { + deletedIDs = append(deletedIDs, doc["_id"].(bson.ObjectID)) + } + return +} + +func (m *mongoDB) CleanRedis(ctx context.Context, filter bson.M, tenantId interface{}, collection string) (err error) { + listKeys := fmt.Sprintf(redis.CleanList, tenantId, collection) + keys, err := redis.RedisClient().Keys(ctx, listKeys) + if err != nil { + return + } + for _, key := range keys { + _, err = redis.RedisClient().Del(ctx, key) + if err != nil { + return + } + } + countKeys := fmt.Sprintf(redis.CleanCount, tenantId, collection) + keys, err = redis.RedisClient().Keys(ctx, countKeys) + if err != nil { + return + } + for _, key := range keys { + _, err = redis.RedisClient().Del(ctx, key) + if err != nil { + return + } + } + filter["isDeleted"] = false + delete(filter, "tenantId") + filterKey := fmt.Sprintf("%+v", filter) + oneKey := fmt.Sprintf(redis.One, tenantId, collection, filterKey) + _, err = redis.RedisClient().Del(ctx, oneKey) + if err != nil { + return + } + return +} + +func (m *mongoDB) log(ctx context.Context, ids []bson.ObjectID, filter bson.M, collection string, data interface{}, userName, tenantId interface{}, operationType consts.OperationType) { + // 提前获取 IP 地址,避免异步任务执行时请求已结束 + var ipAddress string + if request := g.RequestFromCtx(ctx); request != nil { + ipAddress = request.GetClientIp() + } + if operationType != consts.OperationInsert && operationType != consts.OperationDelete { + if !g.IsEmpty(filter["_id"]) { + objectID := filter["_id"].(*bson.ObjectID) + ids = append(ids, *objectID) + } else { + var err error + if ids, _, err = m.getDeletedData(ctx, filter, collection); err != nil { + return + } + } + } + log := &entity.OperationLog{ + ServiceName: serverName, + Collection: collection, + CollectionID: ids, + Operation: string(operationType), + IPAddress: ipAddress, + Data: data, + } + log.Creator = userName + log.Updater = userName + now := >ime.Now().Time + log.CreatedAt = now + log.UpdatedAt = now + log.TenantId = tenantId + // 使用新的 context 进行 Redis 操作 + if _, err := redis.AddToStream(ctx, LogRedisKey, log); err != nil { + glog.Error(ctx, "mongoLog-AddToStream err: %v", err) + } + return +} + +// Insert 插入多条记录 +func (m *mongoDB) Insert(ctx context.Context, documents []interface{}, collection string, opts ...options.Lister[options.InsertManyOptions]) (ids []interface{}, err error) { + source, err := m.getDataSource() + if err != nil { + return nil, err + } + db := source.Database() + + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + docs := make([]interface{}, 0, len(documents)) + for _, document := range documents { + doc := gconv.Map(document) + delete(doc, "id") + if !g.IsEmpty(user.UserName) && g.IsEmpty(doc["creator"]) { + doc["creator"] = user.UserName + } + if !g.IsEmpty(user.UserName) && g.IsEmpty(doc["updater"]) { + doc["updater"] = user.UserName + } + if !g.IsEmpty(user.TenantId) && g.IsEmpty(doc["tenantId"]) { + doc["tenantId"] = user.TenantId + } + if g.IsEmpty(doc["createdAt"]) { + doc["createdAt"] = gtime.Now().Time + } + if g.IsEmpty(doc["updatedAt"]) { + doc["updatedAt"] = gtime.Now().Time + } + doc["isDeleted"] = false + docs = append(docs, doc) + } + r, err := db.Collection(collection).InsertMany(ctx, docs, opts...) + if err != nil { + return + } + ids = r.InsertedIDs + err = m.CleanRedis(ctx, bson.M{}, user.TenantId, collection) + //写日志 + if collection != consts.OperationLogCollection { + objectIds := make([]bson.ObjectID, 0) + for _, id := range ids { + objectIds = append(objectIds, id.(bson.ObjectID)) + } + m.log(ctx, objectIds, nil, collection, nil, user.UserName, user.TenantId, consts.OperationInsert) + } + return +} + +// Delete 删除记录 +func (m *mongoDB) Delete(ctx context.Context, filter bson.M, collection string, opts ...options.Lister[options.DeleteManyOptions]) (count int64, err error) { + source, err := m.getDataSource() + if err != nil { + return 0, err + } + db := source.Database() + + if len(filter) == 0 { + err = gerror.New("缺少查询条件") + return + } + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + filter["tenantId"] = user.TenantId + // 获取要删除的数据 + ds, ms, err := m.getDeletedData(ctx, filter, collection) + if err != nil { + return + } + // 执行删除操作 + r, err := db.Collection(collection).DeleteMany(ctx, filter, opts...) + if err != nil { + return + } + count = r.DeletedCount + // 清理redis + err = m.CleanRedis(ctx, filter, user.TenantId, collection) + // 写日志 + m.log(ctx, ds, nil, collection, ms, user.UserName, user.TenantId, consts.OperationDelete) + return +} + +// DeleteSoft 假删除记录 +func (m *mongoDB) DeleteSoft(ctx context.Context, filter bson.M, collection string, opts ...options.Lister[options.UpdateManyOptions]) (modifiedCount int64, err error) { + update := bson.M{"$set": bson.M{"isDeleted": true}} + return m.Update(ctx, filter, update, collection, opts...) +} + +// Update 修改记录 +func (m *mongoDB) Update(ctx context.Context, filter bson.M, update bson.M, collection string, opts ...options.Lister[options.UpdateManyOptions]) (modifiedCount int64, err error) { + source, err := m.getDataSource() + if err != nil { + return 0, err + } + db := source.Database() + + if len(filter) == 0 { + err = gerror.New("缺少查询条件") + return + } + filter["isDeleted"] = false + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + if !g.IsEmpty(user.TenantId) { + filter["tenantId"] = user.TenantId + } + // 遍历 update 中的所有操作符和字段,存放到 list 中 + fieldList := make([]FieldInfo, 0) + for _, doc := range update { + if m, ok := doc.(bson.M); ok { + for fieldName, fieldValue := range m { + // 获取到字段名和字段值 + fieldList = append(fieldList, FieldInfo{ + FieldName: fieldName, + FieldValue: fieldValue, + }) + } + } + } + setDoc := bson.M{} + if !g.IsEmpty(update["$set"]) { + setDoc = update["$set"].(bson.M) + } + if !g.IsEmpty(user.UserName) { + setDoc["updater"] = user.UserName + } + setDoc["updatedAt"] = gtime.Now().Time + update["$set"] = setDoc + result, err := db.Collection(collection).UpdateMany(ctx, filter, update, opts...) + if err != nil { + return + } + modifiedCount = result.ModifiedCount + // 清理redis + err = m.CleanRedis(ctx, filter, user.TenantId, collection) + // 写日志 + if !g.IsEmpty(setDoc["isDeleted"]) && gconv.Bool(setDoc["isDeleted"]) { + filter["isDeleted"] = true + m.log(ctx, nil, filter, collection, nil, user.UserName, user.TenantId, consts.OperationDeleteSoft) + } else { + m.log(ctx, nil, filter, collection, fieldList, user.UserName, user.TenantId, consts.OperationUpdate) + } + return +} + +// SaveOrUpdate 批量增加或修改 +func (m *mongoDB) SaveOrUpdate(ctx context.Context, filter []bson.M, update []bson.M, collection string, opts ...options.Lister[options.UpdateManyOptions]) (result *mongo.BulkWriteResult, err error) { + source, err := m.getDataSource() + if err != nil { + return nil, err + } + db := source.Database() + + if len(filter) == 0 || len(update) == 0 { + err = gerror.New("缺少查询条件或更新数据") + return + } + if len(filter) != len(update) { + err = gerror.New("查询条件和更新数据的数量必须一致") + return + } + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + + var models []mongo.WriteModel + for i := 0; i < len(filter); i++ { + filter[i]["isDeleted"] = false + if !g.IsEmpty(user.TenantId) { + filter[i]["tenantId"] = user.TenantId + } + if setDoc, exists := update[i]["$set"].(bson.M); exists { + if !g.IsEmpty(user.UserName) { + setDoc["updater"] = user.UserName + } + setDoc["updatedAt"] = gtime.Now().Time + } else { + setDoc := bson.M{} + if !g.IsEmpty(user.UserName) { + setDoc["updater"] = user.UserName + } + setDoc["updatedAt"] = gtime.Now().Time + update[i]["$set"] = setDoc + } + updateModel := mongo.NewUpdateOneModel() + updateModel.SetFilter(filter[i]) + updateModel.SetUpdate(update[i]) + updateModel.SetUpsert(true) + if len(opts) > 0 { + for _, opt := range opts { + var updateOpts options.UpdateManyOptions + optFuncs := opt.List() + for _, fn := range optFuncs { + fn(&updateOpts) + } + if updateOpts.Upsert != nil { + updateModel.SetUpsert(*updateOpts.Upsert) + } + } + } + models = append(models, updateModel) + } + + bulkOpts := options.BulkWrite().SetOrdered(false) + bulkResult, err := db.Collection(collection).BulkWrite(ctx, models, bulkOpts) + if err != nil { + return nil, err + } + + for _, filterItem := range filter { + err = m.CleanRedis(ctx, filterItem, user.TenantId, collection) + if err != nil { + glog.Warning(ctx, "清理Redis缓存失败:", err) + } + } + return bulkResult, nil +} + +func BuildUpdateData(ctx context.Context, req interface{}) (filter bson.M, err error) { + _ = ctx + filter = bson.M{} + reqMap := gconv.Map(req) + for mk, mv := range reqMap { + if mk != "id" && !g.IsEmpty(mv) { + filter[mk] = mv + } + } + return +} diff --git a/elasticsearch/client.go b/elasticsearch/client.go new file mode 100644 index 0000000..4159f31 --- /dev/null +++ b/elasticsearch/client.go @@ -0,0 +1,118 @@ +package elasticsearch + +import ( + "context" + "sync" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/olivere/elastic/v7" +) + +var ( + client *elastic.Client + clientOnce sync.Once +) + +// Config ES 配置 +type Config struct { + Addresses []string // ES 地址列表 + Username string // 用户名 + Password string // 密码 +} + +// Init 初始化 ES 客户端(单例) +func Init(ctx context.Context) (err error) { + clientOnce.Do(func() { + addresses := g.Cfg().MustGet(ctx, "elasticsearch.addresses", []string{"http://localhost:9200"}).Strings() + username := g.Cfg().MustGet(ctx, "elasticsearch.username", "").String() + password := g.Cfg().MustGet(ctx, "elasticsearch.password", "").String() + + options := []elastic.ClientOptionFunc{ + elastic.SetURL(addresses...), + elastic.SetSniff(false), // 禁用嗅探,避免容器环境问题 + } + + if username != "" && password != "" { + options = append(options, elastic.SetBasicAuth(username, password)) + } + + client, err = elastic.NewClient(options...) + if err != nil { + glog.Errorf(ctx, "ES 客户端初始化失败: %v", err) + return + } + + // 测试连接 + info, code, testErr := client.Ping(addresses[0]).Do(ctx) + if testErr != nil { + glog.Warningf(ctx, "ES 连接测试失败(月度归档功能不可用): %v", testErr) + client = nil // 清空客户端 + err = testErr + return + } + glog.Infof(ctx, "ES 连接成功 - 版本: %s, 状态码: %d", info.Version.Number, code) + }) + return +} + +// GetClient 获取 ES 客户端 +func GetClient() *elastic.Client { + return client +} + +// BulkIndex 批量写入文档 +func BulkIndex(ctx context.Context, indexName string, docs []interface{}) (err error) { + if client == nil { + return gerror.New("ES 客户端未初始化") + } + + bulk := client.Bulk().Index(indexName) + for _, doc := range docs { + bulk.Add(elastic.NewBulkIndexRequest().Doc(doc)) + } + + resp, err := bulk.Do(ctx) + if err != nil { + return + } + + if resp.Errors { + for _, item := range resp.Failed() { + glog.Errorf(ctx, "ES 写入失败 - Index: %s, Error: %s", item.Index, item.Error.Reason) + } + } + + glog.Infof(ctx, "ES 批量写入完成 - 索引: %s, 成功: %d, 失败: %d", + indexName, len(resp.Succeeded()), len(resp.Failed())) + return +} + +// CreateIndexIfNotExists 创建索引(如果不存在) +func CreateIndexIfNotExists(ctx context.Context, indexName, mapping string) (err error) { + if client == nil { + return gerror.New("ES 客户端未初始化") + } + + exists, err := client.IndexExists(indexName).Do(ctx) + if err != nil { + return + } + + if !exists { + _, err = client.CreateIndex(indexName).BodyString(mapping).Do(ctx) + if err != nil { + return + } + glog.Infof(ctx, "ES 索引创建成功: %s", indexName) + } + return +} + +// Close 关闭客户端 +func Close() { + if client != nil { + client.Stop() + } +} diff --git a/full-text-search/meilisearch/connection.go b/full-text-search/meilisearch/connection.go new file mode 100644 index 0000000..50c46cd --- /dev/null +++ b/full-text-search/meilisearch/connection.go @@ -0,0 +1,392 @@ +// ============================================================================= +// Meilisearch 数据源连接管理 +// 负责数据源的连接、重连、健康检查和优雅关闭 +// ============================================================================= + +package meilisearch + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/util/gconv" + ms "github.com/meilisearch/meilisearch-go" +) + +// ============================================================================= +// 数据源配置结构 +// ============================================================================= + +type DataSourceConfig struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + APIKey string `json:"apiKey"` + Timeout time.Duration `json:"timeout"` +} + +// ============================================================================= +// 单个数据源接口 +// ============================================================================= + +type DataSource interface { + Name() string + Client() interface{} + IsConnected() bool + Connect(ctx context.Context) error + Reconnect(ctx context.Context) error + Close(ctx context.Context) error +} + +// ============================================================================= +// 数据源实现 +// ============================================================================= + +type BaseDataSource struct { + config *DataSourceConfig + client interface{} + isConnected bool + mu sync.RWMutex + lastError error + lastErrorTime time.Time +} + +func NewBaseDataSource(config *DataSourceConfig) *BaseDataSource { + return &BaseDataSource{ + config: config, + isConnected: false, + } +} + +func (d *BaseDataSource) Name() string { + return d.config.Name +} + +func (d *BaseDataSource) Client() interface{} { + d.mu.RLock() + defer d.mu.RUnlock() + return d.client +} + +func (d *BaseDataSource) IsConnected() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.isConnected && d.client != nil +} + +func (d *BaseDataSource) Connect(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + // 构建客户端 + d.client = ms.New(d.config.Host, ms.WithAPIKey(d.config.APIKey)) + + // 测试连接 + if err := d.healthCheck(ctx); err != nil { + d.isConnected = false + d.lastError = err + d.lastErrorTime = time.Now() + return fmt.Errorf("datasource [%s] connection failed: %w", d.config.Name, err) + } + + d.isConnected = true + d.lastError = nil + glog.Infof(ctx, "✅ datasource [%s] connected successfully", d.config.Name) + return nil +} + +// healthCheck 健康检查 +func (d *BaseDataSource) healthCheck(ctx context.Context) error { + if d.client == nil { + return fmt.Errorf("client is nil") + } + + // 获取版本信息来测试连接 + if c, ok := d.client.(interface{ GetVersion() (*ms.Version, error) }); ok { + status, err := c.GetVersion() + if err != nil { + return err + } + glog.Debugf(ctx, "Meilisearch version: %s", status.PkgVersion) + } else { + // 如果没有GetVersion方法,尝试其他方法验证连接 + if _, ok := d.client.(interface { + GetIndexes(interface{}) (interface{}, error) + }); ok { + return nil + } + } + + return nil +} + +func (d *BaseDataSource) Reconnect(ctx context.Context) error { + glog.Infof(ctx, "🔄 reconnecting datasource [%s]", d.config.Name) + return d.Connect(ctx) +} + +func (d *BaseDataSource) Close(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + // Meilisearch 客户端不需要显式关闭,只重置状态 + d.client = nil + d.isConnected = false + glog.Infof(ctx, "datasource [%s] closed", d.config.Name) + return nil +} + +// ============================================================================= +// 多数据源管理器 +// ============================================================================= + +type DataSourceManager struct { + sources map[string]DataSource + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + started bool + maxRetries int +} + +var ( + globalManager *DataSourceManager + managerOnce sync.Once +) + +// GetManager 获取全局管理器 +func GetManager() *DataSourceManager { + managerOnce.Do(func() { + ctx, cancel := context.WithCancel(context.Background()) + globalManager = &DataSourceManager{ + sources: make(map[string]DataSource), + ctx: ctx, + cancel: cancel, + started: false, + maxRetries: 3, + } + }) + return globalManager +} + +// RegisterDataSource 注册数据源 +func (m *DataSourceManager) RegisterDataSource(config *DataSourceConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.sources[config.Name]; exists { + return fmt.Errorf("datasource [%s] already exists", config.Name) + } + + source := NewBaseDataSource(config) + m.sources[config.Name] = source + return nil +} + +// GetDataSource 获取数据源 +func (m *DataSourceManager) GetDataSource(name string) (DataSource, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + source, exists := m.sources[name] + if !exists { + return nil, fmt.Errorf("datasource [%s] not found", name) + } + return source, nil +} + +// GetAllDataSourceNames 获取所有数据源名称 +func (m *DataSourceManager) GetAllDataSourceNames() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + names := make([]string, 0, len(m.sources)) + for name := range m.sources { + names = append(names, name) + } + return names +} + +// init 初始化多数据源 +func init() { + ctx := context.Background() + + // 从配置初始化多数据源 + if err := manager.InitializeFromConfig(ctx); err != nil { + glog.Errorf(ctx, "❌ Failed to initialize Meilisearch datasources: %v", err) + } else { + glog.Infof(ctx, "✅ Meilisearch datasources initialized: %v", manager.GetAllDataSourceNames()) + } + + // 启动健康检查 + manager.StartHealthCheck() + + // 设置优雅关闭 + setupGracefulShutdown() +} + +// InitializeFromConfig 从配置初始化数据源 +// 动态读取 config.yml 中 meilisearch 下的所有配置项 +func (m *DataSourceManager) InitializeFromConfig(ctx context.Context) error { + var firstErr error + + // 获取 meilisearch 配置下的所有子键 + meilisearchConfig := g.Cfg().MustGet(ctx, "meilisearch") + if meilisearchConfig.IsNil() { + glog.Warningf(ctx, "no meilisearch configuration found in config.yml") + return nil + } + + // 将配置转换为 map + configMap := meilisearchConfig.Map() + if configMap == nil { + glog.Warningf(ctx, "meilisearch configuration is not a map") + return nil + } + + // 遍历所有 meilisearch 子配置 + for name, subConfig := range configMap { + // 跳过非对象类型的配置 + subMap, ok := subConfig.(map[string]interface{}) + if !ok { + continue + } + + // 检查是否有 host 配置 + host, hasHost := subMap["host"] + if !hasHost || gconv.String(host) == "" { + continue + } + + // 构建数据源配置 + config := &DataSourceConfig{ + Name: name, + Host: gconv.String(host), + Port: int(gconv.Int(subMap["port"])), + APIKey: gconv.String(subMap["apiKey"]), + } + + // 设置默认值 + if config.Port == 0 { + config.Port = 7700 + } + if config.Host == "" { + config.Host = "http://localhost" + } + + // 可选:从配置读取超时时间 + if timeoutVal, hasTimeout := subMap["timeout"]; hasTimeout { + config.Timeout = gconv.Duration(timeoutVal) + } else { + config.Timeout = 10 * time.Second + } + + // 注册数据源 + if err := m.RegisterDataSource(config); err != nil { + glog.Errorf(ctx, "failed to register datasource [%s]: %v", name, err) + if firstErr == nil { + firstErr = err + } + continue + } + + // 连接数据源 + source, _ := m.GetDataSource(name) + if err := source.Connect(ctx); err != nil { + glog.Errorf(ctx, "failed to initialize datasource [%s]: %v", name, err) + if firstErr == nil { + firstErr = err + } + } + } + + return firstErr +} + +// StartHealthCheck 启动健康检查 +func (m *DataSourceManager) StartHealthCheck() { + if m.started { + return + } + m.started = true + go m.healthCheckLoop() +} + +// healthCheckLoop 健康检查循环 +func (m *DataSourceManager) healthCheckLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + m.checkAndReconnect() + } + } +} + +// checkAndReconnect 检查并重新连接 +func (m *DataSourceManager) checkAndReconnect() { + m.mu.RLock() + defer m.mu.RUnlock() + + for name, source := range m.sources { + if !source.IsConnected() { + glog.Warningf(context.Background(), "datasource [%s] disconnected, attempting reconnect", name) + + reconnectCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := source.Reconnect(reconnectCtx); err != nil { + glog.Errorf(reconnectCtx, "datasource [%s] reconnect failed: %v", name, err) + } else { + glog.Infof(reconnectCtx, "✅ datasource [%s] reconnected successfully", name) + } + } + } +} + +// CloseAll 关闭所有数据源 +func (m *DataSourceManager) CloseAll(ctx context.Context) error { + m.cancel() + + m.mu.RLock() + defer m.mu.RUnlock() + + var lastErr error + for name, source := range m.sources { + if err := source.Close(ctx); err != nil { + glog.Errorf(ctx, "failed to close datasource [%s]: %v", name, err) + lastErr = err + } + } + return lastErr +} + +// setupGracefulShutdown 设置优雅关闭 +func setupGracefulShutdown() { + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + glog.Info(ctx, "🔄 Shutting down Meilisearch connections...") + if err := manager.CloseAll(ctx); err != nil { + glog.Errorf(ctx, "❌ Failed to close Meilisearch connections: %v", err) + } else { + glog.Info(ctx, "✅ Meilisearch connections closed successfully") + } + }() +} diff --git a/full-text-search/meilisearch/meilisearch.go b/full-text-search/meilisearch/meilisearch.go new file mode 100644 index 0000000..e4b561f --- /dev/null +++ b/full-text-search/meilisearch/meilisearch.go @@ -0,0 +1,721 @@ +// ============================================================================= +// Meilisearch 业务操作封装 +// 提供CRUD操作方法,支持多数据源 +// ============================================================================= + +package meilisearch + +import ( + "context" + "fmt" + "time" + + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + ms "github.com/meilisearch/meilisearch-go" +) + +// ============================================================================= +// 向后兼容的Meilisearch结构体 +// ============================================================================= + +type meilisearchDB struct { + noCache bool + dataSource string // 数据源名称,默认为 "default" +} + +func DB(cache ...bool) *meilisearchDB { + return &meilisearchDB{ + noCache: false, + dataSource: "default", + } +} + +// WithDataSource 指定使用的数据源 +func (m *meilisearchDB) WithDataSource(name string) *meilisearchDB { + m.dataSource = name + return m +} + +// NoCache 不使用缓存 +func (m *meilisearchDB) NoCache() *meilisearchDB { + m.noCache = true + return m +} + +// ============================================================================= +// 全局变量 +// ============================================================================= + +var ( + manager = GetManager() +) + +const PageSize = 20 + +// ============================================================================= +// Meilisearch 操作方法(支持多数据源) +// ============================================================================= + +// getDataSource 获取当前使用的数据源 +func (m *meilisearchDB) getDataSource() (DataSource, error) { + if m.dataSource == "" { + m.dataSource = "default" + } + return manager.GetDataSource(m.dataSource) +} + +// getClient 获取 Meilisearch 客户端 +func (m *meilisearchDB) getClient() (interface{ Index(string) interface{} }, error) { + source, err := m.getDataSource() + if err != nil { + return nil, err + } + if c, ok := source.Client().(interface{ Index(string) interface{} }); ok { + return c, nil + } + return nil, fmt.Errorf("invalid client type") +} + +// indexInterface 辅助函数,获取index +func indexInterface(indexName string, client interface{ Index(string) interface{} }) interface{} { + return client.Index(indexName) +} + +// buildSearchRequest 构建搜索请求 +func (m *meilisearchDB) buildSearchRequest(ctx context.Context, searchParams *SearchParams) (*ms.SearchRequest, error) { + user, err := utils.GetUserInfo(ctx) + if err != nil { + return nil, err + } + + req := &ms.SearchRequest{ + Limit: int64(PageSize), + Page: int64(0), + } + + // 设置查询 + if searchParams.Query != "" { + req.Query = searchParams.Query + } + + // 设置分页 + if searchParams.Page > 0 { + req.Page = int64(searchParams.Page - 1) + } + if searchParams.Limit > 0 { + req.Limit = int64(searchParams.Limit) + } + + // 设置排序 + if len(searchParams.Sort) > 0 { + req.Sort = searchParams.Sort + } + + // 设置过滤条件(包含租户过滤和软删除过滤) + filter := "" + if !g.IsEmpty(user.TenantId) { + filter = fmt.Sprintf("tenantId = %s", gconv.String(user.TenantId)) + } + if filter == "" { + filter = "isDeleted = false" + } else { + filter += " AND isDeleted = false" + } + + // 添加用户自定义过滤条件 + if searchParams.Filter != "" { + if filter == "" { + filter = searchParams.Filter + } else { + filter += " AND " + searchParams.Filter + } + } + + if filter != "" { + req.Filter = filter + } + + // 设置可搜索字段 + if searchParams.SearchableAttributes != "" { + req.AttributesToSearchOn = []string{searchParams.SearchableAttributes} + } + + // 设置返回字段 + if len(searchParams.AttributesToRetrieve) > 0 { + req.AttributesToRetrieve = searchParams.AttributesToRetrieve + } + + return req, nil +} + +// Search 搜索文档 +func (m *meilisearchDB) Search(ctx context.Context, searchParams *SearchParams, indexName string, result interface{}) (total int64, err error) { + client, err := m.getClient() + if err != nil { + return 0, err + } + + // 构建搜索请求 + req, err := m.buildSearchRequest(ctx, searchParams) + if err != nil { + return 0, err + } + + // Redis 缓存处理 + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + cacheKey := fmt.Sprintf("meilisearch:search:%s:%s:%+v", user.TenantId, indexName, searchParams) + if !m.noCache { + var resultStr *gvar.Var + resultStr, err = g.Redis().Get(ctx, cacheKey) + if err != nil { + return + } + if !g.IsEmpty(resultStr) { + searchResult := &SearchResult{} + if err = gconv.Struct(resultStr, searchResult); err != nil { + return + } + total = int64(searchResult.EstimatedTotalHits) + if len(searchResult.Hits) > 0 { + if resultArr, ok := result.(*[]map[string]interface{}); ok { + *resultArr = searchResult.Hits + } else { + err = gconv.Structs(searchResult.Hits, result) + if err != nil { + return + } + } + } + return + } + } + + // 执行搜索 + idx := indexInterface(indexName, client) + var searchResp *ms.SearchResponse + if i, ok := idx.(interface { + Search(string, *ms.SearchRequest) (*ms.SearchResponse, error) + }); ok { + searchResp, err = i.Search(searchParams.Query, req) + } else { + return 0, fmt.Errorf("index does not support Search method") + } + if err != nil { + return 0, err + } + + total = int64(searchResp.EstimatedTotalHits) + + // 解析结果 + if len(searchResp.Hits) > 0 { + hits := make([]map[string]interface{}, 0, len(searchResp.Hits)) + for _, hit := range searchResp.Hits { + hitMap := gconv.Map(hit) + // 移除 Meilisearch 内部字段 + delete(hitMap, "_formatted") + hits = append(hits, hitMap) + } + + if resultArr, ok := result.(*[]map[string]interface{}); ok { + *resultArr = hits + } else { + err = gconv.Structs(hits, result) + if err != nil { + return + } + } + } + + // 写入缓存 + if !m.noCache { + hitList := make([]map[string]interface{}, 0) + if len(searchResp.Hits) > 0 { + for _, hit := range searchResp.Hits { + hitMap := gconv.Map(hit) + delete(hitMap, "_formatted") + hitList = append(hitList, hitMap) + } + } + searchResult := &SearchResult{ + Hits: hitList, + EstimatedTotalHits: searchResp.EstimatedTotalHits, + Limit: int(searchResp.Limit), + Offset: int(searchResp.Offset), + ProcessingTimeMs: int(searchResp.ProcessingTimeMs), + } + err = g.Redis().SetEX(ctx, cacheKey, searchResult, int64(time.Hour)) + if err != nil { + return + } + } + + return +} + +// Insert 插入文档 +func (m *meilisearchDB) Insert(ctx context.Context, document interface{}, indexName string) (taskUID int64, err error) { + c, err := m.getClient() + if err != nil { + return 0, err + } + + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + + // 转换为 map + docMap := gconv.Map(document) + + // 设置租户ID + if !g.IsEmpty(user.TenantId) && g.IsEmpty(docMap["tenantId"]) { + docMap["tenantId"] = user.TenantId + } + + // 设置创建人 + if !g.IsEmpty(user.UserName) && g.IsEmpty(docMap["creator"]) { + docMap["creator"] = user.UserName + } + + // 设置更新人 + if !g.IsEmpty(user.UserName) && g.IsEmpty(docMap["updater"]) { + docMap["updater"] = user.UserName + } + + // 设置时间 + now := gtime.Now().Time + if g.IsEmpty(docMap["createdAt"]) { + docMap["createdAt"] = now.Unix() + } + if g.IsEmpty(docMap["updatedAt"]) { + docMap["updatedAt"] = now.Unix() + } + + // 设置删除标记 + if g.IsEmpty(docMap["isDeleted"]) { + docMap["isDeleted"] = false + } + + // 执行插入 + documents := []map[string]interface{}{docMap} + idx := indexInterface(indexName, c) + var task *ms.TaskInfo + if i, ok := idx.(interface { + AddDocuments([]map[string]interface{}, interface{}) (*ms.TaskInfo, error) + }); ok { + task, err = i.AddDocuments(documents, nil) + } else { + return 0, fmt.Errorf("index does not support AddDocuments method") + } + if err != nil { + return 0, err + } + + // 清理缓存 + err = m.cleanCache(ctx, indexName, user.TenantId) + if err != nil { + glog.Warning(ctx, "清理Redis缓存失败:", err) + } + + return task.TaskUID, nil +} + +// InsertMany 批量插入文档 +func (m *meilisearchDB) InsertMany(ctx context.Context, documents []interface{}, indexName string) (taskUID int64, err error) { + c, err := m.getClient() + if err != nil { + return 0, err + } + + user, err := utils.GetUserInfo(ctx) + if err != nil { + return 0, err + } + + docs := make([]map[string]interface{}, 0, len(documents)) + for _, document := range documents { + docMap := gconv.Map(document) + + // 设置租户ID + if !g.IsEmpty(user.TenantId) && g.IsEmpty(docMap["tenantId"]) { + docMap["tenantId"] = user.TenantId + } + + // 设置创建人 + if !g.IsEmpty(user.UserName) && g.IsEmpty(docMap["creator"]) { + docMap["creator"] = user.UserName + } + + // 设置更新人 + if !g.IsEmpty(user.UserName) && g.IsEmpty(docMap["updater"]) { + docMap["updater"] = user.UserName + } + + // 设置时间 + now := gtime.Now().Time + if g.IsEmpty(docMap["createdAt"]) { + docMap["createdAt"] = now.Unix() + } + if g.IsEmpty(docMap["updatedAt"]) { + docMap["updatedAt"] = now.Unix() + } + + // 设置删除标记 + if g.IsEmpty(docMap["isDeleted"]) { + docMap["isDeleted"] = false + } + + docs = append(docs, docMap) + } + + // 执行批量插入 + idx := indexInterface(indexName, c) + var task *ms.TaskInfo + if i, ok := idx.(interface { + AddDocuments([]map[string]interface{}, interface{}) (*ms.TaskInfo, error) + }); ok { + task, err = i.AddDocuments(docs, nil) + } else { + return 0, fmt.Errorf("index does not support AddDocuments method") + } + if err != nil { + return 0, err + } + + // 清理缓存 + err = m.cleanCache(ctx, indexName, user.TenantId) + if err != nil { + glog.Warning(ctx, "清理Redis缓存失败:", err) + } + + return task.TaskUID, nil +} + +// Update 更新文档 +func (m *meilisearchDB) Update(ctx context.Context, document interface{}, indexName string) (taskUID int64, err error) { + c, err := m.getClient() + if err != nil { + return 0, err + } + + user, err := utils.GetUserInfo(ctx) + if err != nil { + return 0, err + } + + // 转换为 map + docMap := gconv.Map(document) + + // 设置更新人 + if !g.IsEmpty(user.UserName) && g.IsEmpty(docMap["updater"]) { + docMap["updater"] = user.UserName + } + + // 设置更新时间 + docMap["updatedAt"] = gtime.Now().Unix() + + // 执行更新 + documents := []map[string]interface{}{docMap} + idx := indexInterface(indexName, c) + var task *ms.TaskInfo + if i, ok := idx.(interface { + UpdateDocuments([]map[string]interface{}, interface{}) (*ms.TaskInfo, error) + }); ok { + task, err = i.UpdateDocuments(documents, nil) + } else { + return 0, fmt.Errorf("index does not support UpdateDocuments method") + } + if err != nil { + return 0, err + } + + // 清理缓存 + err = m.cleanCache(ctx, indexName, user.TenantId) + if err != nil { + glog.Warning(ctx, "清理Redis缓存失败:", err) + } + + return task.TaskUID, nil +} + +// Delete 删除文档 +func (m *meilisearchDB) Delete(ctx context.Context, id string, indexName string) (taskUID int64, err error) { + c, err := m.getClient() + if err != nil { + return 0, err + } + + // 执行删除 + idx := indexInterface(indexName, c) + var task *ms.TaskInfo + if i, ok := idx.(interface { + DeleteDocument(string) (*ms.TaskInfo, error) + }); ok { + task, err = i.DeleteDocument(id) + } else { + return 0, fmt.Errorf("index does not support DeleteDocument method") + } + if err != nil { + return 0, err + } + + // 清理缓存 + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + err = m.cleanCache(ctx, indexName, user.TenantId) + if err != nil { + glog.Warning(ctx, "清理Redis缓存失败:", err) + } + + return task.TaskUID, nil +} + +// DeleteSoft 软删除文档 +func (m *meilisearchDB) DeleteSoft(ctx context.Context, id string, indexName string) (taskUID int64, err error) { + c, err := m.getClient() + if err != nil { + return 0, err + } + + user, err := utils.GetUserInfo(ctx) + if err != nil { + return 0, err + } + + // 软删除:更新 isDeleted 字段 + updateMap := map[string]interface{}{ + "id": id, + "isDeleted": true, + "updater": user.UserName, + "updatedAt": gtime.Now().Unix(), + } + + // 执行更新 + documents := []map[string]interface{}{updateMap} + idx := indexInterface(indexName, c) + var task *ms.TaskInfo + if i, ok := idx.(interface { + UpdateDocuments([]map[string]interface{}, interface{}) (*ms.TaskInfo, error) + }); ok { + task, err = i.UpdateDocuments(documents, nil) + } else { + return 0, fmt.Errorf("index does not support UpdateDocuments method") + } + if err != nil { + return 0, err + } + + // 清理缓存 + err = m.cleanCache(ctx, indexName, user.TenantId) + if err != nil { + glog.Warning(ctx, "清理Redis缓存失败:", err) + } + + return task.TaskUID, nil +} + +// Get 获取单个文档 +func (m *meilisearchDB) Get(ctx context.Context, id string, indexName string, result interface{}) (err error) { + c, err := m.getClient() + if err != nil { + return err + } + + // Redis 缓存处理 + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + cacheKey := fmt.Sprintf("meilisearch:doc:%s:%s:%s", user.TenantId, indexName, id) + if !m.noCache { + var resultStr *gvar.Var + resultStr, err = g.Redis().Get(ctx, cacheKey) + if err != nil { + return + } + if !g.IsEmpty(resultStr) { + return gconv.Scan(resultStr, result) + } + } + + // 执行查询 + var doc map[string]interface{} + idx := indexInterface(indexName, c) + if i, ok := idx.(interface { + GetDocument(string, interface{}) error + }); ok { + err = i.GetDocument(id, &doc) + } else { + return fmt.Errorf("index does not support GetDocument method") + } + if err != nil { + return err + } + + // 过滤已删除的文档 + if gconv.Bool(doc["isDeleted"]) { + return gerror.New("文档不存在") + } + + err = gconv.Struct(doc, result) + if err != nil { + return err + } + + // 写入缓存 + if !m.noCache { + err = g.Redis().SetEX(ctx, cacheKey, result, int64(time.Hour)) + if err != nil { + return err + } + } + + return nil +} + +// cleanCache 清理缓存 +func (m *meilisearchDB) cleanCache(ctx context.Context, indexName string, tenantId interface{}) error { + // 清理搜索缓存 + searchKeys, err := g.Redis().Keys(ctx, fmt.Sprintf("meilisearch:search:%s:%s:*", tenantId, indexName)) + if err != nil { + return err + } + for _, key := range searchKeys { + _, err = g.Redis().Del(ctx, key) + if err != nil { + return err + } + } + return nil +} + +// CreateIndex 创建索引 +func (m *meilisearchDB) CreateIndex(ctx context.Context, indexConfig *IndexConfig) (taskUID int64, err error) { + client, err := m.getClient() + if err != nil { + return 0, err + } + + indexSettings := &ms.IndexConfig{ + Uid: indexConfig.UID, + PrimaryKey: indexConfig.PrimaryKey, + } + + if c, ok := client.(interface { + CreateIndex(*ms.IndexConfig) (*ms.TaskInfo, error) + }); ok { + task, err := c.CreateIndex(indexSettings) + if err != nil { + return 0, err + } + return task.TaskUID, nil + } + return 0, fmt.Errorf("client does not support CreateIndex") +} + +// DeleteIndex 删除索引 +func (m *meilisearchDB) DeleteIndex(ctx context.Context, indexName string) (err error) { + client, err := m.getClient() + if err != nil { + return err + } + + if c, ok := client.(interface{ DeleteIndex(string) error }); ok { + return c.DeleteIndex(indexName) + } + return fmt.Errorf("client does not support DeleteIndex") +} + +// GetIndex 获取索引信息 +func (m *meilisearchDB) GetIndex(ctx context.Context, indexName string) (interface{}, error) { + client, err := m.getClient() + if err != nil { + return nil, err + } + + if c, ok := client.(interface { + GetIndex(string) (interface{}, error) + }); ok { + return c.GetIndex(indexName) + } + return nil, fmt.Errorf("client does not support GetIndex") +} + +// GetIndexes 获取所有索引 +func (m *meilisearchDB) GetIndexes(ctx context.Context) (interface{}, error) { + client, err := m.getClient() + if err != nil { + return nil, err + } + + if c, ok := client.(interface { + GetIndexes(interface{}) (interface{}, error) + }); ok { + return c.GetIndexes(nil) + } + return nil, fmt.Errorf("client does not support GetIndexes") +} + +// UpdateSettings 更新索引设置 +func (m *meilisearchDB) UpdateSettings(ctx context.Context, indexName string, settings *ms.Settings) (taskUID int64, err error) { + c, err := m.getClient() + if err != nil { + return 0, err + } + + idx := indexInterface(indexName, c) + var task *ms.TaskInfo + if i, ok := idx.(interface { + UpdateSettings(*ms.Settings) (*ms.TaskInfo, error) + }); ok { + task, err = i.UpdateSettings(settings) + } else { + return 0, fmt.Errorf("index does not support UpdateSettings method") + } + if err != nil { + return 0, err + } + + return task.TaskUID, nil +} + +// GetSettings 获取索引设置 +func (m *meilisearchDB) GetSettings(ctx context.Context, indexName string) (*ms.Settings, error) { + c, err := m.getClient() + if err != nil { + return nil, err + } + + idx := indexInterface(indexName, c) + var settings *ms.Settings + if i, ok := idx.(interface{ GetSettings() (*ms.Settings, error) }); ok { + settings, err = i.GetSettings() + } else { + return nil, fmt.Errorf("index does not support GetSettings method") + } + if err != nil { + return nil, err + } + return settings, nil +} + +// GetClient 获取原始客户端(用于高级操作) +func (m *meilisearchDB) GetClient() (interface{ Index(string) interface{} }, error) { + return m.getClient() +} + +// BuildUpdateData 构建更新数据 +func BuildUpdateData(ctx context.Context, req interface{}) (map[string]interface{}, error) { + return gconv.Map(req), nil +} diff --git a/full-text-search/meilisearch/types.go b/full-text-search/meilisearch/types.go new file mode 100644 index 0000000..f5e9e65 --- /dev/null +++ b/full-text-search/meilisearch/types.go @@ -0,0 +1,109 @@ +// ============================================================================= +// Meilisearch 数据结构定义 +// ============================================================================= + +package meilisearch + +// SearchParams 搜索参数 +type SearchParams struct { + Query string // 搜索查询字符串 + Page int64 // 页码(从1开始) + Limit int64 // 每页数量 + Sort []string // 排序字段,如 ["createdAt:desc", "price:asc"] + Filter string // 过滤条件 + SearchableAttributes string // 可搜索字段 + AttributesToRetrieve []string // 返回字段 + Facets []string // 聚合字段 + HitsPerPage int // 每页命中数 +} + +// SearchResult 搜索结果 +type SearchResult struct { + Hits []map[string]interface{} `json:"hits"` + EstimatedTotalHits int64 `json:"estimatedTotalHits"` + Limit int `json:"limit"` + Offset int `json:"offset"` + ProcessingTimeMs int `json:"processingTimeMs"` + Query string `json:"query"` + FacetDistribution map[string]interface{} `json:"facetDistribution,omitempty"` +} + +// IndexConfig 索引配置 +type IndexConfig struct { + UID string // 索引唯一标识 + PrimaryKey string // 主键字段名 +} + +// IndexSettings 索引设置 +type IndexSettings struct { + SearchableAttributes []string `json:"searchableAttributes,omitempty"` + DisplayedAttributes []string `json:"displayedAttributes,omitempty"` + FilterableAttributes []string `json:"filterableAttributes,omitempty"` + SortableAttributes []string `json:"sortableAttributes,omitempty"` + RankingRules []string `json:"rankingRules,omitempty"` + StopWords []string `json:"stopWords,omitempty"` + Synonyms map[string][]string `json:"synonyms,omitempty"` + DistinctAttribute string `json:"distinctAttribute,omitempty"` + TypoTolerance *TypoTolerance `json:"typoTolerance,omitempty"` + Pagination *Pagination `json:"pagination,omitempty"` + Faceting *Faceting `json:"faceting,omitempty"` +} + +// TypoTolerance 拼写容错设置 +type TypoTolerance struct { + Enabled bool `json:"enabled"` + MinWordSizeForTypos map[string]int `json:"minWordSizeForTypos"` + DisableOnWords []string `json:"disableOnWords"` + DisableOnAttributes []string `json:"disableOnAttributes"` +} + +// Pagination 分页设置 +type Pagination struct { + MaxTotalHits int `json:"maxTotalHits"` +} + +// Faceting 聚合设置 +type Faceting struct { + MaxValuesPerFacet int `json:"maxValuesPerFacet"` +} + +// TaskResult 任务结果 +type TaskResult struct { + TaskUID int64 `json:"taskUid"` + Status string `json:"status"` + Type string `json:"type"` + Details map[string]interface{} `json:"details,omitempty"` + Error string `json:"error,omitempty"` +} + +// Document 文档 +type Document map[string]interface{} + +// 文档操作符 +const ( + OperatorEquals = "=" + OperatorNotEquals = "!=" + OperatorGreaterThan = ">" + OperatorGreaterOrEqual = ">=" + OperatorLessThan = "<" + OperatorLessOrEqual = "<=" + OperatorAnd = "AND" + OperatorOr = "OR" + OperatorNot = "NOT" +) + +// 排序顺序 +const ( + SortAsc = "asc" + SortDesc = "desc" +) + +// 排名规则 +const ( + RankingRuleWords = "words" + RankingRuleTypo = "typo" + RankingRuleProximity = "proximity" + RankingRuleAttribute = "attribute" + RankingRuleSort = "sort" + RankingRuleExactness = "exactness" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2beb57c --- /dev/null +++ b/go.mod @@ -0,0 +1,166 @@ +module gitea.com/red-future/common + +go 1.25.5 + +require ( + github.com/alibaba/sentinel-golang v1.0.4 + github.com/cloudwego/eino v0.7.26 + github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 + github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 + github.com/gogf/gf/v2 v2.9.5 + github.com/google/uuid v1.6.0 + github.com/meilisearch/meilisearch-go v0.36.1 + github.com/minio/minio-go/v7 v7.0.97 + github.com/nats-io/nats.go v1.48.0 + github.com/olivere/elastic/v7 v7.0.32 + github.com/rabbitmq/amqp091-go v1.10.0 + github.com/smallnest/rpcx v1.9.1 + github.com/tiger1103/gfast-token v1.0.10 + go.mongodb.org/mongo-driver/v2 v2.4.0 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/apache/thrift v0.21.0 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cenk/backoff v2.2.1+incompatible // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgraph-io/badger/v4 v4.2.0 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/dgryski/go-jump v0.0.0-20211018200510-ba001c3ffce0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/edwingeng/doublejump v1.0.1 // indirect + github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.4 // indirect + github.com/go-ping/ping v1.2.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/glog v1.2.5 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/flatbuffers v1.12.1 // indirect + github.com/google/pprof v0.0.0-20250128161936-077ca0a936bf // indirect + github.com/goph/emperror v0.17.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grandcat/zeroconf v1.0.0 // indirect + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/hashicorp/consul/api v1.26.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/serf v0.10.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/juju/ratelimit v1.0.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/klauspost/reedsolomon v1.12.4 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/miekg/dns v1.1.63 // indirect + github.com/minio/crc64nvme v1.1.0 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + github.com/onsi/ginkgo/v2 v2.22.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.9 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/quic-go/quic-go v0.49.0 // indirect + github.com/redis/go-redis/v9 v9.12.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/rubyist/circuitbreaker v2.2.1+incompatible // indirect + github.com/shirou/gopsutil/v3 v3.21.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/smallnest/quick v0.2.0 // indirect + github.com/smallnest/rsocket v0.0.0-20241130031020-4a72eb6ff62a // indirect + github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect + github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect + github.com/tinylib/msgp v1.3.0 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/tklauser/go-sysconf v0.3.6 // indirect + github.com/tklauser/numcpus v0.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/fastrand v1.1.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xtaci/kcp-go v5.4.20+incompatible // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opencensus.io v0.23.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.11.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250128144449-3edf0e91c1ae // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..68fb0ce --- /dev/null +++ b/go.sum @@ -0,0 +1,918 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alibaba/sentinel-golang v1.0.4 h1:i0wtMvNVdy7vM4DdzYrlC4r/Mpk1OKUUBurKKkWhEo8= +github.com/alibaba/sentinel-golang v1.0.4/go.mod h1:Lag5rIYyJiPOylK8Kku2P+a23gdKMMqzQS7wTnjWEpk= +github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs= +github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenk/backoff v2.2.1+incompatible h1:djdFT7f4gF2ttuzRKPbMOWgZajgesItGLwG5FTQKmmE= +github.com/cenk/backoff v2.2.1+incompatible/go.mod h1:7FtoeaSnHoZnmZzz47cM35Y9nSW7tNyaidugnHTaFDE= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.7.26 h1:FD8xnhd8WyV495eJs0Rka8aHnknqj/ljq3ZbwGH/SiQ= +github.com/cloudwego/eino v0.7.26/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/WqiyLeB8ohQ= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-jump v0.0.0-20170409065014-e1f439676b57/go.mod h1:4hKCXuwrJoYvHZxJ86+bRVTOMyJ0Ej+RqfSm8mHi6KA= +github.com/dgryski/go-jump v0.0.0-20211018200510-ba001c3ffce0 h1:0wH6nO9QEa02Qx8sIQGw6ieKdz+BXjpccSOo9vXNl4U= +github.com/dgryski/go-jump v0.0.0-20211018200510-ba001c3ffce0/go.mod h1:4hKCXuwrJoYvHZxJ86+bRVTOMyJ0Ej+RqfSm8mHi6KA= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/edwingeng/doublejump v1.0.1 h1:wJ6QgNyyF23Of9vw+ThbwJ/obe9KdxaWEg/Brpv5S1o= +github.com/edwingeng/doublejump v1.0.1/go.mod h1:ykMWX8JWePtMtk2OGjNE9kwtgpI+SF2FNIyXV4gS36k= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/go-ping/ping v1.2.0 h1:vsJ8slZBZAXNCK4dPcI2PEE9eM9n9RbXbGouVQ/Y4yQ= +github.com/go-ping/ping v1.2.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/godzie44/go-uring v0.0.0-20220926161041-69611e8b13d5 h1:5zELAgnSz0gqmr4Q5DWCoOzNHoeBAxVUXB7LS1eG+sw= +github.com/godzie44/go-uring v0.0.0-20220926161041-69611e8b13d5/go.mod h1:ermjEDUoT/fS+3Ona5Vd6t6mZkw1eHp99ILO5jGRBkM= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1 h1:egobo4YfQX3C4NtrEFunBqMX3jsddagklgut9u91+BM= +github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1/go.mod h1:YQ+u5Cs5N2ETCeQaLbv29z/UWYuxw27mJpUkOLj0kJ8= +github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ= +github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k= +github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 h1:tHUEZYB5GTqEYYVDYnlGobf1xISARKDE4KHVlgjwTec= +github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5/go.mod h1:cfzTn2HS9RDX8f5pUVkbGxUWcSosouqfNQ1G6cY0V88= +github.com/gogf/gf/v2 v2.9.5 h1:1scfOdHbMP854oQaiLejl+eL+c4xfuvtWmmZiDJxbKs= +github.com/gogf/gf/v2 v2.9.5/go.mod h1:VUb5eyJKpvW77O/dXsbbLNO/Kjrg0UycIiq0lRiBjjo= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250128161936-077ca0a936bf h1:BvBLUD2hkvLI3dJTJMiopAq8/wp43AAZKTP7qdpptbU= +github.com/google/pprof v0.0.0-20250128161936-077ca0a936bf/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= +github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= +github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= +github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= +github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= +github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk= +github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA= +github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-sockaddr v0.2.0 h1:Alhhj6lGxVAon9O32tOO89T601EugSx6YiGjy5BVjWk= +github.com/libp2p/go-sockaddr v0.2.0/go.mod h1:5NxulaB17yJ07IpzRIleys4un0PJ7WLWgMDLBBWrGw8= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M= +github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= +github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= +github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/olivere/elastic/v7 v7.0.32 h1:R7CXvbu8Eq+WlsLgxmKVKPox0oOwAE/2T9Si5BnvK6E= +github.com/olivere/elastic/v7 v7.0.32/go.mod h1:c7PVmLe3Fxq77PIfY/bZmxY/TAamBhCzZ8xDOE09a9k= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/peterbourgon/g2s v0.0.0-20140925154142-ec76db4c1ac1 h1:5Dl+ADmsGerAqHwWzyLqkNaUBQ+48DQwfDCaW1gHAQM= +github.com/peterbourgon/g2s v0.0.0-20140925154142-ec76db4c1ac1/go.mod h1:1VcHEd3ro4QMoHfiNl/j7Jkln9+KQuorp0PItHMJYNg= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/quic-go/quic-go v0.49.0 h1:w5iJHXwHxs1QxyBv1EHKuC50GX5to8mJAxvtnttJp94= +github.com/quic-go/quic-go v0.49.0/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rubyist/circuitbreaker v2.2.1+incompatible h1:KUKd/pV8Geg77+8LNDwdow6rVCAYOp8+kHUyFvL6Mhk= +github.com/rubyist/circuitbreaker v2.2.1+incompatible/go.mod h1:Ycs3JgJADPuzJDwffe12k6BZT8hxVi6lFK+gWYJLN4A= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shirou/gopsutil/v3 v3.21.6 h1:vU7jrp1Ic/2sHB7w6UNs7MIkn7ebVtTb5D9j45o9VYE= +github.com/shirou/gopsutil/v3 v3.21.6/go.mod h1:JfVbDpIBLVzT8oKbvMg9P3wEIMDDpVn+LwHTKj0ST88= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smallnest/quick v0.2.0 h1:AEvm7ZovZ6Utv+asFDBh866G4ufMNhRNMKbZHVMFYPE= +github.com/smallnest/quick v0.2.0/go.mod h1:ODNivpfZTaMgYrNb/fhDtqoEe2TTPxSRo8JaIT/QThI= +github.com/smallnest/rpcx v1.9.1 h1:fGw+qMcDRPm7Ei9fdEfqteYY6qQqgVsoyxoUw6hEXy0= +github.com/smallnest/rpcx v1.9.1/go.mod h1:owr4mDCReTn+dy9m5ilof0mBivFBeK0XrkYfZYdDGb4= +github.com/smallnest/rsocket v0.0.0-20241130031020-4a72eb6ff62a h1:GI6kCNC5AVFbKA6ZKbVd4r+fk+Z7XZCRQm9LURZY4t4= +github.com/smallnest/rsocket v0.0.0-20241130031020-4a72eb6ff62a/go.mod h1:VJeIKKrDEzT4ZNVe87JN9uRLw1XLp/ZnnE9PfsyJ1jY= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 h1:89CEmDvlq/F7SJEOqkIdNDGJXrQIhuIx9D2DBXjavSU= +github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161/go.mod h1:wM7WEvslTq+iOEAMDLSzhVuOt5BRZ05WirO+b09GHQU= +github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b h1:fj5tQ8acgNUr6O8LEplsxDhUIe2573iLkJc+PqnzZTI= +github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b/go.mod h1:5XA7W9S6mni3h5uvOC75dA3m9CCCaS83lltmc0ukdi4= +github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= +github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tklauser/go-sysconf v0.3.6 h1:oc1sJWvKkmvIxhDHeKWvZS4f6AW+YcoguSfRF2/Hmo4= +github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= +github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= +github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xtaci/kcp-go v5.4.20+incompatible h1:TN1uey3Raw0sTz0Fg8GkfM0uH3YwzhnZWQ1bABv5xAg= +github.com/xtaci/kcp-go v5.4.20+incompatible/go.mod h1:bN6vIwHQbfHaHtFpEssmWsN45a+AZwO7eyRCmEIbtvE= +github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= +github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.mongodb.org/mongo-driver/v2 v2.4.0 h1:Oq6BmUAAFTzMeh6AonuDlgZMuAuEiUxoAD1koK5MuFo= +go.mongodb.org/mongo-driver/v2 v2.4.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= +golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250128144449-3edf0e91c1ae h1:COZdc9Ut6wLq7MO9GIYxfZl4n4ScmgqQLoHocKXrxco= +golang.org/x/exp v0.0.0-20250128144449-3edf0e91c1ae/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/http/http.go b/http/http.go new file mode 100644 index 0000000..a751095 --- /dev/null +++ b/http/http.go @@ -0,0 +1,177 @@ +package http + +import ( + "context" + "errors" + "fmt" + "net/http" + "reflect" + "regexp" + "strings" + + _ "gitea.com/red-future/common/consul" + "gitea.com/red-future/common/jaeger" + "gitea.com/red-future/common/log/controller" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/net/gsvc" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +var ( + Httpserver = g.Server() + Httpclient = g.Client() + filterPaths = map[string]bool{"/": true, "/*": true, "/api.json": true} + legalRegisteredPaths = make(map[string]bool) +) + +func init() { + err := gtime.SetTimeZone("Asia/Shanghai") + if err != nil { + panic("设置时区失败") + } + //s.Use(common.Cors) //中间件验证 + //s.EnablePProf() //启用性能分析 + Httpserver.BindMiddlewareDefault(validateFilterPathMiddleware) + Httpserver.SetOpenApiPath("/api.json") + Httpserver.SetDumpRouterMap(true) //关闭打印路由注册信息 + Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse) + Httpclient.SetDiscovery(gsvc.GetRegistry()) +} + +func validateFilterPathMiddleware(r *ghttp.Request) { + path := r.URL.Path + if filterPaths[path] && !legalRegisteredPaths[path] { + routes := Httpserver.GetRoutes() + for _, route := range routes { + if filterPaths[route.Handler.Router.Uri] && route.Handler.Router.Uri != "/api.json" { + g.Log().Errorf(r.GetCtx(), "路径 %s 中间件 %s 必须通过 SkipMiddleware 注册", route.Handler.Router.Uri, route.Handler.Name) + } + } + r.ExitAll() + } + r.Middleware.Next() +} + +func SkipMiddleware(h func(r *ghttp.Request), path string) (handler ghttp.HandlerFunc) { + if filterPaths[path] { + legalRegisteredPaths[path] = true + } + return func(r *ghttp.Request) { + if filterPaths[path] { + r.Middleware.Next() + return + } + h(r) + } +} + +func RouteRegister(controllers []interface{}) { + Httpserver.Group("/log", func(group *ghttp.RouterGroup) { + group.Middleware(jaeger.NewTracer) + group.Bind(controller.OperationLog) + }) + Httpserver.BindHandler("/uploadConfig", func(r *ghttp.Request) { + config := g.Config().MustGet(r.GetCtx(), "upload") + r.Response.WriteJsonExit(config) + }) + re := regexp.MustCompile("[A-Z]") + for _, t := range controllers { + sName := reflect.ValueOf(t).Elem().Type().Name() + convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string { + return fmt.Sprintf("/%s", strings.ToLower(s)) + }) + Httpserver.Group(convertedStr, func(group *ghttp.RouterGroup) { + group.Middleware(jaeger.NewTracer) + group.Bind(t) + }) + } + go Httpserver.Run() +} + +// doRequest 统一HTTP请求处理(DELETE用ContentJson发送body,gconv.Struct增加err检查) +func doRequest(ctx context.Context, method string, url string, headers map[string]string, target any, data ...any) (err error) { + err = utils.ValidStructPtr(target) + if err != nil { + return + } + client := Httpclient.Clone() + + // POST/PUT/DELETE请求都需要显式用ContentJson序列化body + if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 { + client = client.ContentJson() + } + + // 最后设置headers,确保不会被ContentJson覆盖 + if len(headers) > 0 { + client.SetHeaderMap(headers) + } else { + client.SetHeader("Authorization", g.RequestFromCtx(ctx).GetHeader("Authorization")) + } + + // 修复:避免data...展开导致的双重包装问题 + // 当只有一个元素时,直接传递该元素,避免被包装成数组 + var response *gclient.Response + // 对于GET请求,将参数转换为map + if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 { + // 构建query参数map + queryParams := make(map[string]string) + for i := 0; i < len(data); i += 2 { + if key, ok := data[i].(string); ok && i+1 < len(data) { + queryParams[key] = gconv.String(data[i+1]) + } + } + g.Log().Infof(ctx, "[HTTP] GET请求构建的query参数: %+v", queryParams) + response, err = client.DoRequest(ctx, method, url, queryParams) + } else if len(data) == 1 { + response, err = client.DoRequest(ctx, method, url, data[0]) + } else { + response, err = client.DoRequest(ctx, method, url, data...) + } + if err != nil { + return + } + defer response.Close() + result := response.ReadAll() + + // 统一处理内部API响应格式:{code:200,message:"",data:{...}} + resultStrut := &ghttp.DefaultHandlerResponse{} + + if err = gconv.Struct(result, &resultStrut); err != nil { // 修复:增加err检查 + return errors.New("响应解析失败: " + err.Error()) + } + + // 添加调试日志:打印解析后的结构 + g.Log().Debugf(ctx, "[HTTP] 解析后结构: Code=%d, Message=%s, Data类型=%T, Data值=%+v", + resultStrut.Code, resultStrut.Message, resultStrut.Data, resultStrut.Data) + + if resultStrut.Code == 200 || resultStrut.Code == 0 { + if err = gconv.Struct(resultStrut.Data, target); err != nil { // 修复:增加err检查 + return errors.New("数据解析失败: " + err.Error()) + } + // 添加调试日志:打印最终的target + g.Log().Debugf(ctx, "[HTTP] 最终target: %+v", target) + } else { + err = errors.New(resultStrut.Message) + } + return +} +func Get(ctx context.Context, url string, headers map[string]string, target any, data ...any) (err error) { + err = doRequest(ctx, http.MethodGet, url, headers, target, data...) + return +} +func Post(ctx context.Context, url string, headers map[string]string, target any, data ...any) (err error) { + err = doRequest(ctx, http.MethodPost, url, headers, target, data...) + return +} +func Put(ctx context.Context, url string, headers map[string]string, target any, data ...any) (err error) { + err = doRequest(ctx, http.MethodPut, url, headers, target, data...) + return +} +func Delete(ctx context.Context, url string, headers map[string]string, target any, data ...any) (err error) { + err = doRequest(ctx, http.MethodDelete, url, headers, target, data...) + return +} diff --git a/jaeger/jaeger.go b/jaeger/jaeger.go new file mode 100644 index 0000000..0132dd3 --- /dev/null +++ b/jaeger/jaeger.go @@ -0,0 +1,164 @@ +package jaeger + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "sync" + + "github.com/gogf/gf/contrib/trace/otlphttp/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/net/gtrace" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var ( + ShutDown func(ctx context.Context) + initOnce sync.Once +) + +// Init 初始化 Jaeger 链路追踪(延迟初始化,首次调用时执行) +func Init() { + initOnce.Do(func() { + ctx := context.Background() + jaegerAgent := g.Cfg().MustGet(ctx, "jaeger.addr").String() + serverName := g.Cfg().MustGet(ctx, "server.name").String() + + if jaegerAgent == "" { + g.Log().Warning(ctx, "⚠️ Jaeger 配置未找到,跳过初始化") + ShutDown = func(ctx context.Context) {} // 空函数,避免 nil panic + return + } + + shutdown, err := otlphttp.Init(serverName, jaegerAgent, "/v1/traces") + if err != nil { + g.Log().Errorf(ctx, "Jaeger 初始化失败: %v", err) + ShutDown = func(ctx context.Context) {} + return + } + ShutDown = shutdown + g.Log().Infof(ctx, "✅ Jaeger 初始化成功: %s", jaegerAgent) + }) +} + +func init() { + // 默认自动初始化(保持向后兼容) + Init() +} + +// NewSpan 创建新的链路追踪 Span +// spanName: Span 名称,用于在 Jaeger UI 中标识 +// 返回带有 Span 的 context 和 Span 对象,调用方需 defer span.End() +func NewSpan(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, *gtrace.Span) { + return gtrace.NewSpan(ctx, spanName, opts...) +} + +// RecordError 统一错误记录方法 +// 功能: +// 1. 控制台输出错误(带完整堆栈 %+v) +// 2. Jaeger 链路追踪记录错误 +// 3. 设置 Span 错误状态 +// +// 使用示例: +// +// jaeger.RecordError(ctx, err, "保存数据失败") +// +// 参数: +// - ctx: 包含 trace span 的上下文 +// - err: 错误对象(支持 gerror 堆栈) +// - msg: 可选的错误描述(用于日志和 Jaeger 显示) +func RecordError(ctx context.Context, err error, msg ...string) { + if err == nil { + return + } + // 1. 控制台输出(%+v 打印完整堆栈) + if len(msg) > 0 && msg[0] != "" { + g.Log().Errorf(ctx, "%s: %+v", msg[0], err) + } else { + g.Log().Errorf(ctx, "%+v", err) + } + // 2. Jaeger 记录(从 context 获取当前 span) + span := trace.SpanFromContext(ctx) + if span == nil || !span.IsRecording() { + return + } + // 3. 记录错误到 span + span.RecordError(err) + span.SetAttributes( + attribute.Bool("error", true), + attribute.String("error.message", err.Error()), + ) + // 4. 设置 span 状态为错误 + if len(msg) > 0 && msg[0] != "" { + span.SetAttributes(attribute.String("error.msg", msg[0])) + span.SetStatus(codes.Error, msg[0]+": "+err.Error()) + return + } + span.SetStatus(codes.Error, err.Error()) +} + +// NewTracer HTTP 请求链路追踪中间件 +// 功能: +// 1. 为每个 HTTP 请求创建 Span +// 2. 记录请求参数和响应内容 +// 3. 自动捕获错误并记录到 Jaeger +// +// 使用方式:在路由组中注册为中间件 +// +// group.Middleware(jaeger.NewTracer) +func NewTracer(r *ghttp.Request) { + // 创建 Span(名称取自 controller 方法的 summary 标签) + ctx, span := gtrace.NewSpan(r.Context(), r.GetServeHandler().GetMetaTag("summary")) + r.SetCtx(ctx) + defer span.End() + // 记录请求参数 + span.SetAttributes(attribute.String("request", getParams(r))) + // 执行后续中间件和 handler + r.Middleware.Next() + + // 清理响应字符串,确保 UTF-8 有效(处理二进制数据如 ZIP 文件) + response := r.Response.BufferString() + cleanResponse := strings.ToValidUTF8(response, "") + + // 如果响应太大(如文件下载),只记录前 1000 字符 + if len(cleanResponse) > 1000 { + cleanResponse = cleanResponse[:1000] + "... (truncated)" + } + + span.SetAttributes(attribute.String("response", cleanResponse)) + span.SetAttributes(attribute.Int("http.status_code", r.Response.Status)) + + if err := r.GetError(); err != nil { + RecordError(ctx, err) + return + } + if r.Response.Status >= 500 { + span.SetAttributes(attribute.Bool("error", true)) + span.SetStatus(codes.Error, "http status "+strconv.Itoa(r.Response.Status)) + } +} + +// getParams 提取请求参数(用于 Jaeger 记录) +func getParams(r *ghttp.Request) string { + params := map[string]interface{}{} + if r.Method == "POST" { + json.Unmarshal(r.GetBody(), ¶ms) //获取raw传参 + } + if r.Method == "GET" { + r.Request.ParseForm() + form := r.Form + for k, v := range form { + if vl, e := strconv.Atoi(v[0]); e == nil { + params[k] = vl + } else { + params[k] = v[0] + } + } + } + rp, _ := json.Marshal(¶ms) + return string(rp) +} diff --git a/log/consts/log_const.go b/log/consts/log_const.go new file mode 100644 index 0000000..af621b1 --- /dev/null +++ b/log/consts/log_const.go @@ -0,0 +1,24 @@ +package consts + +// OperationType 操作类型常量 +type OperationType string + +const ( + OperationInsert OperationType = "insert" // 创建 + OperationUpdate OperationType = "update" // 更新 + OperationDelete OperationType = "delete" // 删除 + OperationDeleteSoft OperationType = "delete_soft" // 软删除 +) + +// OperationLogCollection 操作日志集合名称常量 +const ( + OperationLogCollection = "operation_logs" // 操作日志集合名称 +) + +// 消费者配置(从 Redis Stream 消费请求) +const StreamKey = "log:%s" // 请求 Stream 键名(与发消息的key一致) +const ConsumerName = "log-consumer" // 消费者名称(唯一标识) +const BatchSize = 1 // 批处理大小(每次读取1条) +const AutoAck = true // ACK是否自动确认(true自动确认,false不确认) + +const LogSubject = "log:subject" diff --git a/log/controller/log_controller.go b/log/controller/log_controller.go new file mode 100644 index 0000000..a222b07 --- /dev/null +++ b/log/controller/log_controller.go @@ -0,0 +1,18 @@ +package controller + +import ( + "context" + + "gitea.com/red-future/common/log/model/dto" + "gitea.com/red-future/common/log/service" +) + +type operationLog struct{} + +// OperationLog 操作日志控制器 +var OperationLog = new(operationLog) + +// GetByCollectionId 根据collectionId获取操作日志列表 +func (c *operationLog) GetByCollectionId(ctx context.Context, req *dto.ListLogsReq) (res *dto.ListLogsResp, err error) { + return service.OperationLog.GetByCollectionId(ctx, req) +} diff --git a/log/dao/log_dao.go b/log/dao/log_dao.go new file mode 100644 index 0000000..ee04b7b --- /dev/null +++ b/log/dao/log_dao.go @@ -0,0 +1,77 @@ +package dao + +import ( + "context" + + "gitea.com/red-future/common/beans" + "gitea.com/red-future/common/db/mongo" + + "strings" + "time" + + "gitea.com/red-future/common/log/consts" + "gitea.com/red-future/common/log/model/dto" + "gitea.com/red-future/common/log/model/entity" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type log struct{} + +// Log 日志数据访问对象 +var Log = &log{} + +// Create 创建日志记录 +func (d *log) Create(ctx context.Context, log *entity.OperationLog) error { + _, err := mongo.DB().Insert(ctx, []interface{}{log}, consts.OperationLogCollection) + return err +} + +// List 查询日志列表(通用方法,通过filter动态拼接查询条件) +func (d *log) List(ctx context.Context, req *dto.ListLogsReq) (res []*entity.OperationLog, total int64, err error) { + filter := buildFilter(req) + req.OrderBy = []beans.OrderBy{ + {Field: "createdAt", Order: beans.Desc}, + } + total, err = mongo.DB().Find(ctx, filter, &res, consts.OperationLogCollection, req.Page, req.OrderBy) + return +} + +// buildFilter 构建MongoDB查询过滤器 +func buildFilter(filter interface{}) bson.M { + bsonFilter := make(bson.M) + + // 从ListLogsReq结构体中提取字段值 + if req, ok := filter.(*dto.ListLogsReq); ok { + if req.ServiceName != "" { + bsonFilter["service_name"] = req.ServiceName + } + if req.Collection != "" { + bsonFilter["collection"] = req.Collection + } + if req.CollectionID != "" { + bsonFilter["collection_id"] = bson.M{"$in": strings.Split(req.CollectionID, ",")} + } + if req.Operation != "" { + bsonFilter["operation"] = req.Operation + } + // 处理时间范围字段 + if req.StartTime != "" || req.EndTime != "" { + timeFilter := bson.M{} + if req.StartTime != "" { + if startTime, err := time.Parse("2006-01-02 15:04:05", req.StartTime); err == nil { + timeFilter["$gte"] = startTime + } + } + if req.EndTime != "" { + if endTime, err := time.Parse("2006-01-02 15:04:05", req.EndTime); err == nil { + timeFilter["$lte"] = endTime + } + } + if len(timeFilter) > 0 { + bsonFilter["createdAt"] = timeFilter + } + } + } + + return bsonFilter +} diff --git a/log/model/dto/log_dto.go b/log/model/dto/log_dto.go new file mode 100644 index 0000000..ddbc167 --- /dev/null +++ b/log/model/dto/log_dto.go @@ -0,0 +1,39 @@ +package dto + +import ( + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ListLogsReq 查询操作日志列表请求(通用方法,支持根据不同条件动态查询) +type ListLogsReq struct { + g.Meta `path:"/listLogs" method:"get" tags:"操作日志" summary:"查询操作日志列表" dc:"根据多个条件查询操作日志列表"` + *beans.Page + ServiceName string `json:"service_name" dc:"服务名(可选)"` + Collection string `json:"collection" dc:"数据所在集合名称(可选)"` + CollectionID string `json:"collection_id" dc:"数据ID(可选)"` + Operation string `json:"operation" dc:"操作类型(可选)"` + StartTime string `json:"start_time" dc:"开始时间(可选)"` + EndTime string `json:"end_time" dc:"结束时间(可选)"` + OrderBy []beans.OrderBy `json:"orderBy" dc:"排序字段"` +} + +// ListLogsResp 查询操作日志列表响应 +type ListLogsResp struct { + Logs []OperationLogInfo `json:"logs" dc:"日志列表"` + Total int64 `json:"total" dc:"总数"` +} + +// OperationLogInfo 操作日志信息 +type OperationLogInfo struct { + ID string `json:"id" dc:"日志ID"` + ServiceName string `json:"service_name" dc:"服务名"` + Collection string `json:"collection" dc:"数据所在集合名称"` + CollectionID interface{} `json:"collection_id" dc:"数据ID"` + Operation string `json:"operation" dc:"操作类型"` + Creator string `json:"creator" dc:"操作人名称"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + Data interface{} `json:"data" dc:"当前数据"` + IPAddress string `json:"ip_address" dc:"操作IP地址"` +} diff --git a/log/model/entity/log.go b/log/model/entity/log.go new file mode 100644 index 0000000..40fd54f --- /dev/null +++ b/log/model/entity/log.go @@ -0,0 +1,17 @@ +package entity + +import ( + "gitea.com/red-future/common/beans" +) + +// OperationLog 操作日志实体 - 用于记录数据增删改操作行为 +type OperationLog struct { + beans.MongoBaseDO `bson:",inline"` + + ServiceName string `bson:"service_name" json:"service_name"` // 服务名:具体的微服务名称 + Collection string `bson:"collection" json:"collection"` // 集合名:数据所在的集合名称 + CollectionID interface{} `bson:"collection_id" json:"collection_id"` // 数据ID:具体操作的数据ID,如订单号、钱包ID等 + Operation string `bson:"operation" json:"operation"` // 操作类型:create, update, delete + IPAddress string `bson:"ip_address" json:"ip_address"` // 操作IP地址 + Data interface{} `bson:"data,omitempty" json:"data"` // 当前数据:操作时的数据状态 +} diff --git a/log/service/log_service.go b/log/service/log_service.go new file mode 100644 index 0000000..f790378 --- /dev/null +++ b/log/service/log_service.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "gitea.com/red-future/common/beans" + "gitea.com/red-future/common/log/dao" + "gitea.com/red-future/common/log/model/dto" + logEntity "gitea.com/red-future/common/log/model/entity" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/util/gconv" +) + +type operationLog struct{} + +// OperationLog 操作日志服务 +var OperationLog = &operationLog{} + +func (s *operationLog) AddOperationLog(ctx context.Context, msgData any) error { + msg := gconv.MapStrStr(msgData) + serviceName := gconv.String(msg["service_name"]) + collection := gconv.String(msg["collection"]) + collectionId := gconv.Strings(msg["collection_id"]) + operation := gconv.String(msg["operation"]) + ipAddress := gconv.String(msg["ip_address"]) + data := gconv.Maps(msg["data"]) + creator := gconv.String(msg["creator"]) + createdAt := gconv.Time(msg["createdAt"]) + updater := gconv.String(msg["updater"]) + updatedAt := gconv.Time(msg["updatedAt"]) + tenantId := gconv.Float64(msg["tenantId"]) + // 设置 userId 和 tenantId 到 ctx + ctx = context.WithValue(ctx, "userName", creator) + ctx = context.WithValue(ctx, "tenantId", tenantId) + + log := &logEntity.OperationLog{ + MongoBaseDO: beans.MongoBaseDO{ + Creator: creator, + CreatedAt: &createdAt, + Updater: updater, + UpdatedAt: &updatedAt, + TenantId: tenantId, + }, + ServiceName: serviceName, + Collection: collection, + CollectionID: collectionId, + Operation: operation, + IPAddress: ipAddress, + Data: data, + } + return dao.Log.Create(ctx, log) +} + +// GetByCollectionId 根据集合ID获取操作日志 +func (s *operationLog) GetByCollectionId(ctx context.Context, req *dto.ListLogsReq) (res *dto.ListLogsResp, err error) { + logs, total, err := dao.Log.List(ctx, req) + if err != nil { + return + } + res = &dto.ListLogsResp{ + Total: total, + } + err = utils.Struct(logs, &res.Logs) + if err != nil { + return + } + return +} diff --git a/message/connection_nats.go b/message/connection_nats.go new file mode 100644 index 0000000..5b33021 --- /dev/null +++ b/message/connection_nats.go @@ -0,0 +1,167 @@ +package message + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/nats-io/nats.go" +) + +var ( + muNats sync.RWMutex + natsConns map[string]*nats.Conn // key: 数据源名称, value: NATS 连接 + natsJS map[string]nats.JetStreamContext // key: 数据源名称, value: JetStream 上下文 +) + +func init() { + natsConns = make(map[string]*nats.Conn) + natsJS = make(map[string]nats.JetStreamContext) +} + +// natsConnect 建立 NATS 连接 +func natsConnect(ctx context.Context, name string) error { + + if g.Cfg().MustGet(ctx, "nats").IsEmpty() { + g.Log().Errorf(ctx, "❌ NATS 配置不存在") + return fmt.Errorf("NATS Configuration does not exist") + } + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + g.Log().Infof(ctx, "🔔 NATS [%s] 开始创建连接", dsName) + muNats.Lock() + defer muNats.Unlock() + + // 安全地关闭旧连接(仅针对该数据源) + if oldConn, exists := natsConns[dsName]; exists && oldConn != nil && !oldConn.IsClosed() { + oldConn.Close() + delete(natsConns, dsName) + delete(natsJS, dsName) + } + + // 从配置文件读取 NATS 地址 + natsURL := g.Cfg().MustGet(ctx, fmt.Sprintf("nats.%s.url", dsName)).String() + if natsURL == "" { + // 默认使用本地地址 + natsURL = nats.DefaultURL + } + + // 连接选项配置 + opts := []nats.Option{ + nats.Name(fmt.Sprintf("goframe-nats-client-%s", dsName)), + nats.NoReconnect(), + nats.PingInterval(10 * time.Second), + nats.MaxPingsOutstanding(5), + nats.ClosedHandler(func(nc *nats.Conn) { + g.Log().Infof(ctx, "NATS [%s] 连接已关闭: %s", dsName, nc.ConnectedUrl()) + }), + nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) { + g.Log().Errorf(ctx, "❌ NATS [%s] 错误: %v", dsName, err) + }), + } + + newConn, err := nats.Connect(natsURL, opts...) + if err != nil { + g.Log().Errorf(ctx, "❌ NATS [%s] 连接失败: %v", dsName, err) + return err + } + + // 等待连接就绪 + if newConn.Status() != nats.CONNECTED { + select { + case <-time.After(5 * time.Second): + // 连接超时,清理资源 + newConn.Close() + g.Log().Errorf(ctx, "❌ NATS [%s] 连接超时", dsName) + return fmt.Errorf("NATS 连接超时") + case <-newConn.StatusChanged(nats.CONNECTED): + // 连接成功 + g.Log().Infof(ctx, "✅ NATS [%s] 连接成功: %s", dsName, newConn.ConnectedUrl()) + case <-ctx.Done(): + // 外部上下文被取消,清理资源 + newConn.Close() + g.Log().Errorf(ctx, "NATS [%s] 连接被取消: %v", dsName, ctx.Err()) + return fmt.Errorf("NATS 连接被取消: %w", ctx.Err()) + } + } + + // 创建 JetStream 实例 + newJS, err := newConn.JetStream(nats.MaxWait(10 * time.Second)) + if err != nil { + // 创建 JetStream 失败,清理连接 + newConn.Close() + g.Log().Errorf(ctx, "❌ NATS [%s] 创建 JetStream 失败: %v", dsName, err) + return err + } + + // 保存连接和 JetStream 上下文 + natsConns[dsName] = newConn + natsJS[dsName] = newJS + + return nil +} + +// natsPing 检测 NATS 连接状态 +func natsPing(ctx context.Context, name string) bool { + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + muNats.RLock() + defer muNats.RUnlock() + + nc, exists := natsConns[dsName] + if !exists || nc == nil || nc.IsClosed() || nc.Status() != nats.CONNECTED { + g.Log().Errorf(ctx, "❌ NATS [%s] 连接已关闭或不可用", dsName) + return false + } + g.Log().Infof(ctx, "📊 NATS [%s] 连接正常: %s", dsName, nc.ConnectedUrl()) + return true +} + +// natsClose 关闭 NATS 连接 +func natsClose(ctx context.Context, name string) error { + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + muNats.Lock() + defer muNats.Unlock() + + if nc, exists := natsConns[dsName]; exists && nc != nil && !nc.IsClosed() { + nc.Close() + } + delete(natsConns, dsName) + delete(natsJS, dsName) + + g.Log().Infof(ctx, "✅ NATS [%s] 连接已关闭", dsName) + return nil +} + +// getNatsConn 获取 NATS 连接(内部使用) +func getNatsConn(name string) *nats.Conn { + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + return natsConns[dsName] +} + +// getNatsJS 获取 JetStream 上下文(内部使用) +func getNatsJS(name string) nats.JetStreamContext { + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + return natsJS[dsName] +} diff --git a/message/connection_rabbitmq.go b/message/connection_rabbitmq.go new file mode 100644 index 0000000..8b8d47a --- /dev/null +++ b/message/connection_rabbitmq.go @@ -0,0 +1,164 @@ +package message + +import ( + "context" + "fmt" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + amqp "github.com/rabbitmq/amqp091-go" + "sync" +) + +var ( + muRabbitMQ sync.RWMutex + rabbitmqConns map[string]*amqp.Connection + rabbitmqChannels map[string]*amqp.Channel +) + +func init() { + rabbitmqConns = make(map[string]*amqp.Connection) + rabbitmqChannels = make(map[string]*amqp.Channel) +} + +// rabbitmqConnect 建立 RabbitMQ 连接 +func rabbitmqConnect(ctx context.Context, name string) error { + if g.Cfg().MustGet(ctx, "rabbitmq").IsEmpty() { + g.Log().Errorf(ctx, "❌ RabbitMQ 配置不存在") + return fmt.Errorf("RabbitMQ Configuration does not exist") + } + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + g.Log().Infof(ctx, "🔔 RabbitMQ [%s] 开始创建连接", dsName) + muRabbitMQ.Lock() + defer muRabbitMQ.Unlock() + + // 安全地关闭旧连接(仅针对该数据源) + if oldConn, exists := rabbitmqConns[dsName]; exists && oldConn != nil && !oldConn.IsClosed() { + oldConn.Close() + } + if oldChannel, exists := rabbitmqChannels[dsName]; exists && oldChannel != nil && !oldChannel.IsClosed() { + oldChannel.Close() + } + delete(rabbitmqConns, dsName) + delete(rabbitmqChannels, dsName) + + // 从配置文件读取 RabbitMQ 配置 + host := g.Cfg().MustGet(ctx, fmt.Sprintf("rabbitmq.%s.host", dsName)).String() + port := g.Cfg().MustGet(ctx, fmt.Sprintf("rabbitmq.%s.port", dsName)).Int() + username := g.Cfg().MustGet(ctx, fmt.Sprintf("rabbitmq.%s.username", dsName)).String() + password := g.Cfg().MustGet(ctx, fmt.Sprintf("rabbitmq.%s.password", dsName)).String() + vHost := g.Cfg().MustGet(ctx, fmt.Sprintf("rabbitmq.%s.vhost", dsName), "/").String() + if g.IsEmpty(host) { + return fmt.Errorf("❌ RabbitMQ 配置错误: host 不能为空 (数据源: %s)", dsName) + } + if g.IsEmpty(port) { + return fmt.Errorf("❌ RabbitMQ 配置错误: port 不能为空 (数据源: %s)", dsName) + } + if g.IsEmpty(username) { + return fmt.Errorf("❌ RabbitMQ 配置错误: username 不能为空 (数据源: %s)", dsName) + } + if g.IsEmpty(password) { + return fmt.Errorf("❌ RabbitMQ 配置错误: password 不能为空 (数据源: %s)", dsName) + } + // 构建连接 URL + url := "amqp://" + username + ":" + password + "@" + host + ":" + gconv.String(port) + "/" + vHost + + // 创建连接 + newConn, err := amqp.Dial(url) + if err != nil { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] 连接失败: %v", dsName, err) + return err + } + + // 创建 Channel + newChannel, err := newConn.Channel() + if err != nil { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] 创建 Channel 失败: %v", dsName, err) + newConn.Close() + return err + } + + // 保存连接和 Channel + rabbitmqConns[dsName] = newConn + rabbitmqChannels[dsName] = newChannel + + g.Log().Infof(ctx, "✅ RabbitMQ [%s] 连接成功", dsName) + return nil +} + +// rabbitmqPing 检测 RabbitMQ 连接状态 +func rabbitmqPing(ctx context.Context, name string) bool { + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + muRabbitMQ.RLock() + defer muRabbitMQ.RUnlock() + + conn, exists := rabbitmqConns[dsName] + channel, channelExists := rabbitmqChannels[dsName] + if !exists || conn == nil || conn.IsClosed() || !channelExists || channel == nil || channel.IsClosed() { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] 连接已关闭或不可用", dsName) + return false + } + + g.Log().Infof(ctx, "📊 RabbitMQ [%s] 连接正常", dsName) + return true +} + +// rabbitmqClose 关闭 RabbitMQ 连接 +func rabbitmqClose(ctx context.Context, name string) error { + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + muRabbitMQ.Lock() + defer muRabbitMQ.Unlock() + + var lastErr error + + if channel, exists := rabbitmqChannels[dsName]; exists && channel != nil && !channel.IsClosed() { + if err := channel.Close(); err != nil { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] 关闭 Channel 失败: %v", dsName, err) + lastErr = err + } + } + delete(rabbitmqChannels, dsName) + + if conn, exists := rabbitmqConns[dsName]; exists && conn != nil && !conn.IsClosed() { + if err := conn.Close(); err != nil { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] 关闭连接失败: %v", dsName, err) + lastErr = err + } + } + delete(rabbitmqConns, dsName) + + g.Log().Infof(ctx, "✅ RabbitMQ [%s] 连接已关闭", dsName) + return lastErr +} + +// getRabbitMQConn 获取 RabbitMQ 连接(内部使用) +func getRabbitMQConn(name string) *amqp.Connection { + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + return rabbitmqConns[dsName] +} + +// getRabbitMQChannel 获取 RabbitMQ Channel(内部使用) +func getRabbitMQChannel(name string) *amqp.Channel { + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + return rabbitmqChannels[dsName] +} diff --git a/message/connection_redis.go b/message/connection_redis.go new file mode 100644 index 0000000..528eca0 --- /dev/null +++ b/message/connection_redis.go @@ -0,0 +1,198 @@ +// ============================================================================= +// Redis 连接管理 +// 负责 Redis 的连接、重连、健康检查和优雅关闭 +// ============================================================================= + +package message + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/frame/g" +) + +var ( + muRedis sync.RWMutex + redisConns map[string]*gredis.Redis + redisConfigs map[string]*gredis.Config +) + +func init() { + redisConns = make(map[string]*gredis.Redis) + redisConfigs = make(map[string]*gredis.Config) +} + +// redisConnect 建立 Redis 连接 +// name: 数据源名称,如果为空则使用默认数据源 +func redisConnect(ctx context.Context, name string) error { + if g.Cfg().MustGet(ctx, "redis").IsEmpty() { + g.Log().Errorf(ctx, "❌ Redis 配置不存在") + return fmt.Errorf("redis Configuration does not exist") + } + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + g.Log().Infof(ctx, "🔔 Redis [%s] 开始创建连接", dsName) + muRedis.Lock() + defer muRedis.Unlock() + + // 安全地关闭旧连接(仅针对该数据源) + if oldRedis, exists := redisConns[dsName]; exists && oldRedis != nil { + oldRedis.Close(ctx) + delete(redisConns, dsName) + } + + // 从配置文件读取 Redis 配置 + redisAddr := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.address", dsName)).String() + if g.IsEmpty(redisAddr) { + g.Log().Errorf(ctx, "❌ Redis 配置错误: address 不能为空 (数据源: %s)", dsName) + return fmt.Errorf("❌ Redis 配置错误: address 不能为空 (数据源: %s)", dsName) + } + redisDB := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.db", dsName)).Int() + if redisDB < 0 || redisDB > 15 { + g.Log().Errorf(ctx, "❌ Redis 配置错误: db 必须在 0-15 之间 (当前值: %d)", redisDB) + return fmt.Errorf("❌ Redis 配置错误: db 必须在 0-15 之间 (当前值: %d)", redisDB) + } + idleTimeout := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.idleTimeout", dsName)).String() + redisIdleTimeout, err := time.ParseDuration(idleTimeout) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis idleTimeout 格式错误: %v", err) + return err + } + maxConnLifetime := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.maxConnLifetime", dsName)).String() + redisMaxConnLifetime, err := time.ParseDuration(maxConnLifetime) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis maxConnLifetime 格式错误: %v", err) + return err + } + waitTimeout := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.waitTimeout", dsName)).String() + redisWaitTimeout, err := time.ParseDuration(waitTimeout) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis waitTimeout 格式错误: %v", err) + return err + } + dialTimeout := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.dialTimeout", dsName)).String() + redisDialTimeout, err := time.ParseDuration(dialTimeout) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis dialTimeout 格式错误: %v", err) + return err + } + readTimeout := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.readTimeout", dsName)).String() + redisReadTimeout, err := time.ParseDuration(readTimeout) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis readTimeout 格式错误: %v", err) + return err + } + writeTimeout := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.writeTimeout", dsName)).String() + redisWriteTimeout, err := time.ParseDuration(writeTimeout) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis writeTimeout 格式错误: %v", err) + return err + } + maxActive := g.Cfg().MustGet(ctx, fmt.Sprintf("redis.%s.maxActive", dsName)).Int() + if g.IsEmpty(maxActive) { + g.Log().Errorf(ctx, "❌ Redis maxActive 配置错误: %v", maxActive) + return fmt.Errorf("❌ Redis maxActive 配置错误") + } + // 构建 GoFrame Redis 配置 + redisConfig := &gredis.Config{ + Address: redisAddr, + Db: redisDB, + IdleTimeout: redisIdleTimeout, + MaxConnLifetime: redisMaxConnLifetime, + WaitTimeout: redisWaitTimeout, + DialTimeout: redisDialTimeout, + ReadTimeout: redisReadTimeout, + WriteTimeout: redisWriteTimeout, + MaxActive: maxActive, + } + redisConfigs[dsName] = redisConfig + + // 使用 GoFrame 的 Redis 连接 + newRedis, err := gredis.New(redisConfig) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis [%s] 连接失败: %v", dsName, err) + return err + } + // 测试连接(直接调用避免死锁) + _, err = newRedis.Do(ctx, "PING") + if err != nil { + g.Log().Errorf(ctx, "❌ Redis [%s] 连接失败: ping 失败 - %v", dsName, err) + _ = newRedis.Close(ctx) + return err + } + + redisConns[dsName] = newRedis + g.Log().Infof(ctx, "✅ Redis [%s] 连接成功: %s (DB: %d)", dsName, redisAddr, redisDB) + return nil +} + +// redisPing 检测 Redis 连接状态(带超时保护) +func redisPing(ctx context.Context, name string) bool { + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + muRedis.RLock() + defer muRedis.RUnlock() + + rc, exists := redisConns[dsName] + if !exists || rc == nil { + g.Log().Errorf(ctx, "❌ Redis [%s] 连接未建立", dsName) + return false + } + + // 创建带超时的子上下文,避免死锁 + timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + _, err := rc.Do(timeoutCtx, "PING") + if err != nil { + g.Log().Errorf(ctx, "❌ Redis [%s] ping 失败: %v", dsName, err) + return false + } + + g.Log().Infof(ctx, "📊 Redis [%s] 连接正常", dsName) + return true +} + +// redisClose 关闭 Redis 连接 +func redisClose(ctx context.Context, name string) error { + // 确定数据源名称 + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + + muRedis.Lock() + defer muRedis.Unlock() + + if rc, exists := redisConns[dsName]; exists && rc != nil { + if err := rc.Close(ctx); err != nil { + g.Log().Errorf(ctx, "❌ Redis [%s] 关闭失败: %v", dsName, err) + return err + } + delete(redisConns, dsName) + } + + g.Log().Infof(ctx, "✅ Redis [%s] 连接已关闭", dsName) + return nil +} + +// getRedisConn 获取 Redis 连接(内部使用) +func getRedisConn(name string) *gredis.Redis { + dsName := "default" + if !g.IsEmpty(name) { + dsName = name + } + return redisConns[dsName] +} diff --git a/message/msg_interfaces.go b/message/msg_interfaces.go new file mode 100644 index 0000000..db1c86d --- /dev/null +++ b/message/msg_interfaces.go @@ -0,0 +1,32 @@ +package message + +import "context" + +type messagePublishConfig interface { + GetPublishMsgType() +} + +type messagePublishDelayConfig interface { + GetPublishDelayMsgType() +} + +type messageSubscribeConfig interface { + GetSubscribeMsgType() +} + +// messageUtil 消息队列公共配置接口 +// 只暴露核心的发布/订阅功能,配置访问器方法不需要在公共接口中 +type messageUtil interface { + // Publish 发布消息 + Publish(ctx context.Context, msg messagePublishConfig) error + // PublishDelay 发布延迟消息 + PublishDelay(ctx context.Context, msg messagePublishDelayConfig) error + // Subscribe 订阅消息 + Subscribe(ctx context.Context, msg messageSubscribeConfig) error + // Ping 检测连接状态 + Ping(ctx context.Context) bool + // Connect 连接 + Connect(ctx context.Context) error + // Close 关闭连接 + Close(ctx context.Context) error +} diff --git a/message/msg_plugin_manager.go b/message/msg_plugin_manager.go new file mode 100644 index 0000000..8340a8a --- /dev/null +++ b/message/msg_plugin_manager.go @@ -0,0 +1,114 @@ +package message + +import ( + "context" + "fmt" + "time" + + "github.com/gogf/gf/v2/frame/g" + "sync" +) + +// MessageType 消息队列类型 +type messageType string + +const ( + // MessageRedis Redis 消息队列 + MessageRedis messageType = "redis" + // MessageRabbitMQ RabbitMQ 消息队列 + MessageRabbitMQ messageType = "rabbitmq" + // MessageNATS NATS 消息队列 + MessageNATS messageType = "nats" +) + +// configFactory 消息队列配置工厂函数类型 +type configFactory func() messageUtil + +// PluginManager 消息队列插件管理器 +type pluginManager struct { + mu sync.RWMutex + instances map[messageType]messageUtil // 已连接的插件实例 +} + +var ( + defaultPluginManager = newPluginManager() +) + +// newPluginManager 创建插件管理器 +func newPluginManager() *pluginManager { + return &pluginManager{ + instances: make(map[messageType]messageUtil), + } +} + +// register 注册插件(内部方法) +func (m *pluginManager) register(msgType messageType, instance messageUtil) error { + m.mu.Lock() + defer m.mu.Unlock() + m.instances[msgType] = instance + return nil +} + +// RegisterPlugin 注册消息队列插件 +// 所有插件必须通过此方法注册,自动进行连接检测 +// 只有连接成功的插件才会被注册,连接失败的插件不会被注册 +// 异步无限重连,只有连接成功了才注册 +// name: 数据源名称,用于标识不同的连接实例 +func RegisterPlugin(ctx context.Context, name string, msgType messageType, factory configFactory) error { + if factory == nil { + g.Log().Errorf(ctx, "❌ factory cannot be nil") + return fmt.Errorf("factory cannot be nil") + } + // 开启异步连接,无限重试直到成功 + go func() { + // 创建实例 + instance := factory() + // 创建通知 channel + pluginKey := fmt.Sprintf("%s-%s", msgType, name) + if !instance.Ping(ctx) { + // 使用统一的重连函数 + if err := commonConnect(ctx, msgType, name, func(ctx context.Context) error { + return instance.Connect(ctx) + }, func(ctx context.Context) error { + return instance.Close(ctx) + }); err != nil { + g.Log().Errorf(ctx, "❌ [%s][%s] 连接失败: %v", msgType, name, err) + return + } + } + // 连接成功,注册插件 + defaultPluginManager.mu.Lock() + defaultPluginManager.instances[messageType(pluginKey)] = instance + defaultPluginManager.mu.Unlock() + g.Log().Infof(ctx, "✅ [%s][%s] 插件注册成功", msgType, name) + }() + return nil +} + +// GetMsgPlugin 获取消息队列插件(默认数据源),如果未注册则等待 +func GetMsgPlugin(ctx context.Context, msgType messageType) (messageUtil, error) { + return GetMsgPluginWithName(ctx, msgType, "default") +} + +// GetMsgPluginWithName 获取指定数据源的消息队列插件,如果未注册则等待直到超时 +func GetMsgPluginWithName(ctx context.Context, msgType messageType, name string) (messageUtil, error) { + pluginKey := fmt.Sprintf("%s-%s", msgType, name) + + for { + defaultPluginManager.mu.RLock() + instance, ok := defaultPluginManager.instances[messageType(pluginKey)] + defaultPluginManager.mu.RUnlock() + + if ok { + return instance, nil + } + + // 未注册,等待一段时间后重试 + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for plugin ready canceled: %s with datasource: %s", msgType, name) + default: + time.Sleep(3 * time.Second) + } + } +} diff --git a/message/nats_msg.go b/message/nats_msg.go new file mode 100644 index 0000000..365c171 --- /dev/null +++ b/message/nats_msg.go @@ -0,0 +1,373 @@ +package message + +import ( + "context" + "encoding/json" + "fmt" + "github.com/gogf/gf/v2/frame/g" + "github.com/nats-io/nats.go" + "time" +) + +type NatsPublishMsgConfig struct { + QueueName string + Durable bool + Data any +} + +type NatsPublishDelayMsgConfig struct { + QueueName string + Durable bool + DelayTime int + Data any +} + +type NatsSubscribeMsgConfig struct { + QueueName string + ConsumerName string + Durable bool + DelayTime int + AutoAck bool + PrefetchCount int + HandleFunc func(ctx context.Context, message map[string]interface{}) error +} + +func (*NatsPublishMsgConfig) GetPublishMsgType() { + +} + +func (*NatsPublishDelayMsgConfig) GetPublishDelayMsgType() { + +} + +func (*NatsSubscribeMsgConfig) GetSubscribeMsgType() { + +} + +type natsMsg struct { + name string // 数据源名称 +} + +func init() { + // 注册 Nats 插件(默认数据源) + RegisterPlugin(context.Background(), "default", MessageNATS, func() messageUtil { + return &natsMsg{name: "default"} + }) +} + +// Connect 连接 NATS +func (c *natsMsg) Connect(ctx context.Context) error { + return natsConnect(ctx, c.name) +} + +// Ping 检测 NATS 连接状态 +func (c *natsMsg) Ping(ctx context.Context) bool { + return natsPing(ctx, c.name) +} + +// Close 关闭 NATS 连接 +func (c *natsMsg) Close(ctx context.Context) error { + return natsClose(ctx, c.name) +} + +// Publish 发布消息 +func (c *natsMsg) Publish(ctx context.Context, msgConfig messagePublishConfig) error { + cfg, ok := msgConfig.(*NatsPublishMsgConfig) + if !ok { + return fmt.Errorf("无效的 NATS 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("必须提供队列名称") + } + if g.IsEmpty(cfg.Data) { + return fmt.Errorf("必须提供数据") + } + return c.createPublish(ctx, cfg.QueueName, cfg.Durable, 0, cfg.Data) +} + +// PublishDelay 发布延迟消息 +func (c *natsMsg) PublishDelay(ctx context.Context, msgConfig messagePublishDelayConfig) error { + cfg, ok := msgConfig.(*NatsPublishDelayMsgConfig) + if !ok { + return fmt.Errorf("无效的 NATS 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("必须提供队列名称") + } + if g.IsEmpty(cfg.DelayTime) { + return fmt.Errorf("延迟时间必须大于 0") + } + if g.IsEmpty(cfg.Data) { + return fmt.Errorf("必须提供数据") + } + return c.createPublish(ctx, cfg.QueueName, cfg.Durable, cfg.DelayTime, cfg.Data) +} + +// Publish 发布消息 +func (c *natsMsg) createPublish(ctx context.Context, subject string, durable bool, delayTime int, data any) error { + delayMsg := delayTime > 0 + if err := c.createStream(ctx, subject, durable, delayMsg); err != nil { + return err + } + payload, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("序列化数据失败: %w", err) + } + + m := nats.NewMsg(subject) + m.Data = payload // 所有消息都需要设置数据 + + if delayMsg { + // 使用 @at 指定具体延迟时间,而不是 @every 重复执行 + futureTime := time.Now().Add(time.Duration(delayTime) * time.Second).Format(time.RFC3339Nano) + m.Header.Set("Nats-Schedule", fmt.Sprintf("@at %s", futureTime)) + m.Subject = subject + ".schedule" + m.Header.Set("Nats-Schedule-Target", subject) + g.Log().Infof(ctx, "📅 NATS 延迟消息配置: DelayTime=%ds, Schedule=@at %s, Header=%s", delayTime, futureTime, m.Header) + } + + // 发布消息到 JetStream + js := getNatsJS(c.name) + if js == nil { + g.Log().Errorf(ctx, "❌ NATS [%s] JetStream 不存在", c.name) + return fmt.Errorf("NATS JetStream 不存在") + } + ack, err := js.PublishMsg(m) + if err != nil { + g.Log().Errorf(ctx, "❌ NATS 发布消息失败: err=%v, Subject=%s", err, m.Subject) + return err + } + g.Log().Infof(ctx, "✅ NATS 发布消息成功: Stream=%v, StreamSeq=%d", ack.Stream, ack.Sequence) + return nil +} + +// Subscribe 订阅消息 +func (c *natsMsg) Subscribe(ctx context.Context, msgConfig messageSubscribeConfig) error { + cfg, ok := msgConfig.(*NatsSubscribeMsgConfig) + if !ok { + return fmt.Errorf("无效的 NATS 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("必须提供队列名称") + } + if g.IsEmpty(cfg.ConsumerName) { + return fmt.Errorf("必须提供消费者名称") + } + if g.IsEmpty(cfg.HandleFunc) { + return fmt.Errorf("必须提供处理函数") + } + if g.IsEmpty(cfg.PrefetchCount) { + cfg.PrefetchCount = 1 + } + return c.createSubscribe(ctx, cfg.QueueName, cfg.ConsumerName, cfg.PrefetchCount, cfg.DelayTime, cfg.AutoAck, cfg.Durable, cfg.HandleFunc) +} + +// createSubscribe 内部订阅消息 +func (c *natsMsg) createSubscribe(ctx context.Context, subject, consumerName string, prefetchCount, delayTime int, autoAck, durable bool, handler func(ctx context.Context, message map[string]any) error) error { + g.Log().Infof(ctx, "🔔 NATS 开始订阅: QueueName=%s, ConsumerName=%s", subject, consumerName) + // 创建推送订阅的回调函数 + msgHandler := func(msg *nats.Msg) { + var data map[string]any + if err := json.Unmarshal(msg.Data, &data); err != nil { + g.Log().Errorf(ctx, "❌ 解析消息失败: %v", err) + return + } + g.Log().Infof(ctx, "📨 收到消息: Subject=%s, Data=%v", msg.Subject, data) + + // 处理业务逻辑 + if err := handler(ctx, data); err != nil { + g.Log().Errorf(ctx, "❌ 处理消息失败: %v", err) + if !autoAck { + if err := msg.Nak(); err != nil { + g.Log().Errorf(ctx, "❌ Nak 失败: %v", err) + return + } + return + } + } else { + g.Log().Infof(ctx, "✅ 处理消息成功") + } + if err := msg.Ack(); err != nil { + g.Log().Errorf(ctx, "❌ Ack 失败: %v", err) + } + } + delayMsg := delayTime > 0 + // 创建流 + if err := c.createStream(ctx, subject, durable, delayMsg); err != nil { + return err + } + // 获取 JetStream 上下文 + js := getNatsJS(c.name) + if js == nil { + g.Log().Errorf(ctx, "❌ NATS [%s] JetStream 不存在", c.name) + return fmt.Errorf("NATS JetStream 不存在") + } + // 创建推送订阅 + var sub *nats.Subscription + var err error + // 配置订阅选项 - 使用 DeliverSubject 创建 Push Consumer + subOpts := []nats.SubOpt{ + nats.Durable(consumerName), + nats.MaxAckPending(prefetchCount), + nats.DeliverSubject(consumerName), + } + if !autoAck { + subOpts = append(subOpts, nats.ManualAck()) + } + // 使用 Subscribe 创建推送订阅 + sub, err = js.Subscribe(subject, msgHandler, subOpts...) + if err != nil { + g.Log().Errorf(ctx, "创建推送订阅失败: %v", err) + return err + } + g.Log().Infof(ctx, "✅ NATS 推送订阅成功: Consumer=%s", consumerName) + // 启动后台 goroutine 监听上下文取消,用于清理订阅 + go func() { + <-ctx.Done() + g.Log().Infof(ctx, "订阅上下文取消,取消订阅") + if err := sub.Unsubscribe(); err != nil { + return + } + }() + + return nil +} + +// createStream 内部创建消费组 +func (c *natsMsg) createStream(ctx context.Context, subject string, durable, delayMsg bool) error { + streamName, storage := getStreamInfo(durable, delayMsg) + // 构建流配置 + // 如果是延迟消息,需要包含两个 subjects: + // 1. subject.schedule - 用于发送调度消息 + // 2. subject - 用于实际投递目标 + subjects := []string{subject} + if delayMsg { + subjects = []string{subject, subject + ".schedule"} + } + jsConfig := &StreamConfig{ + Name: streamName, + Subjects: subjects, + AllowMsgSchedules: delayMsg, // 延迟消息核心开关 + Storage: storage, + Discard: DiscardNew, // 达到上限删除旧消息 + } + nc := getNatsConn(c.name) + if !c.Ping(ctx) { + // 使用统一的重连函数 + if err := commonConnect(ctx, MessageNATS, c.name, func(ctx context.Context) error { + return c.Connect(ctx) + }, func(ctx context.Context) error { + return c.Close(ctx) + }); err != nil { + g.Log().Errorf(ctx, "❌ [%s][%s] 连接失败: %v", MessageNATS, c.name, err) + return err + } + } + if nc == nil { + g.Log().Errorf(ctx, "❌ NATS [%s] 连接不存在", c.name) + return fmt.Errorf("NATS 连接不存在") + } + err := jsStreamCreate(nc, jsConfig) + if err != nil { + g.Log().Errorf(ctx, "❌ 创建 Stream 失败: err=%v", err) + return err + } + g.Log().Infof(ctx, "✅ 创建 Stream 成功: stream=%s, subjects=%v, allowSchedules=%v", streamName, subjects, delayMsg) + return nil +} + +func getStreamInfo(durable, delayMsg bool) (string, StorageType) { + // Stream 不存在,创建新的 + streamName := "ordinary_msg_memory" + storage := MemoryStorage + + // 延迟消息必须使用 FileStorage(NATS 官方要求) + if delayMsg { + if durable { + streamName = "delay_msg_file" + storage = FileStorage + } else { + streamName = "delay_msg_memory" + storage = MemoryStorage + } + } else { + if durable { + streamName = "ordinary_msg_file" + storage = FileStorage + } + } + return streamName, storage +} + +const ( + // JSApiStreamCreateT is the endpoint to create new streams. + // Will return JSON response. + JSApiStreamCreateT = "$JS.API.STREAM.CREATE.%s" + + // JSApiStreamUpdateT is the endpoint to update existing streams. + // Will return JSON response. + JSApiStreamUpdateT = "$JS.API.STREAM.UPDATE.%s" +) + +// jsStreamCreate is for sending a stream create for fields that nats.go does not know about yet. +func jsStreamCreate(nc *nats.Conn, cfg *StreamConfig) error { + j, err := json.Marshal(cfg) + if err != nil { + return err + } + + msg, err := nc.Request(fmt.Sprintf(JSApiStreamCreateT, cfg.Name), j, time.Second*3) + if err != nil { + return err + } + + // 检查 API 响应中的错误 + var resp struct { + Error *struct { + Code int `json:"code"` + ErrCode int `json:"err_code"` + Description string `json:"description"` + } `json:"error,omitempty"` + } + if err := json.Unmarshal(msg.Data, &resp); err != nil { + return err + } + if resp.Error != nil { + // 如果 Stream 已存在,尝试更新 + if resp.Error.ErrCode == 10058 { // JSStreamNameExistErr + return jsStreamUpdate(nc, cfg) + } + return fmt.Errorf("JS API error: %s", resp.Error.Description) + } + + return nil +} + +// jsStreamUpdate is for sending a stream create for fields that nats.go does not know about yet. +func jsStreamUpdate(nc *nats.Conn, cfg *StreamConfig) error { + j, err := json.Marshal(cfg) + if err != nil { + return err + } + msg, err := nc.Request(fmt.Sprintf(JSApiStreamUpdateT, cfg.Name), j, time.Second*3) + if err != nil { + return err + } + + // 检查 API 响应中的错误 + var resp struct { + Error *struct { + Code int `json:"code"` + ErrCode int `json:"err_code"` + Description string `json:"description"` + } `json:"error,omitempty"` + } + if err := json.Unmarshal(msg.Data, &resp); err != nil { + return err + } + if resp.Error != nil { + return fmt.Errorf("JS API error: %s", resp.Error.Description) + } + + return nil +} diff --git a/message/nats_rpc.go b/message/nats_rpc.go new file mode 100644 index 0000000..8e09938 --- /dev/null +++ b/message/nats_rpc.go @@ -0,0 +1,770 @@ +package message + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/gogf/gf/v2/frame/g" + "github.com/nats-io/nats.go" + "go.opentelemetry.io/otel/trace" + "reflect" + "sync" +) + +// ============ RPC 服务封装 ============ +// 以下方法提供了完全抽象的 RPC 调用接口 +// 调用方和响应方完全不需要知道底层使用的是 NATS 的发布订阅模式 + +// RPC 服务注册表 +var ( + rpcServices map[string]rpcHandler + rpcSubs map[string]*nats.Subscription // 服务名 -> 订阅 + rpcServicesMu sync.RWMutex + queueRPCServices map[string]map[string]rpcHandler // queueName -> subject -> handler + queueRPCSubs map[string]map[string]*nats.Subscription // queueName -> serviceName -> 订阅 + queueRPCMu sync.RWMutex + + // ============ TraceID 主动取消支持 ============ + // 全局映射表:TraceID -> CancelFunc,并发安全 + traceCancelMap map[string]context.CancelFunc + traceCancelMu sync.RWMutex + // 取消主题前缀 + cancelSubjectPrefix = "ctx.cancel.otel." + + // RPC 使用的默认数据源名称 + rpcDefaultDatasource = "default" +) + +// rpcHandler RPC 处理函数类型 +// 实现方只需要关注请求参数和返回值,无需了解底层 NATS 实现 +// 返回值可以是任意类型,会被自动序列化为 JSON +type rpcHandler func(ctx context.Context, req []byte) (any, error) + +// registerRPCService 注册 RPC 服务(单实例) +// serviceName: 服务名称,调用方通过此名称调用服务 +// handler: 服务处理函数,接收请求并返回响应 +func registerRPCService(serviceName string, handler rpcHandler) (err error) { + if !natsPing(context.Background(), rpcDefaultDatasource) { + return fmt.Errorf("NATS 未连接") + } + + rpcServicesMu.Lock() + if rpcServices == nil { + rpcServices = make(map[string]rpcHandler) + } + if rpcSubs == nil { + rpcSubs = make(map[string]*nats.Subscription) + } + + // 如果已存在该服务,先取消之前的订阅 + if oldSub, exists := rpcSubs[serviceName]; exists { + oldSub.Unsubscribe() + } + + rpcServices[serviceName] = handler + rpcServicesMu.Unlock() + + // 订阅服务主题 + nc := getNatsConn(rpcDefaultDatasource) + if nc == nil { + return fmt.Errorf("NATS 连接不存在") + } + + subject := fmt.Sprintf("rpc.%s", serviceName) + sub, err := nc.Subscribe(subject, func(msg *nats.Msg) { + // 执行处理函数 + executeHandler(handler, msg) + }) + + if err != nil { + return fmt.Errorf("注册 RPC 服务失败: %w", err) + } + + rpcSubs[serviceName] = sub + g.Log().Infof(context.Background(), "✅ RPC 服务已注册: %s", serviceName) + return nil +} + +// registerQueueRPCService 注册 RPC 服务(集群模式) +// 多个服务实例注册同一服务时,请求会自动负载均衡 +// serviceName: 服务名称 +// queueName: 队列组名,同一队列组的实例共享请求 +// handler: 服务处理函数 +func registerQueueRPCService(serviceName, queueName string, handler rpcHandler) (err error) { + if !natsPing(context.Background(), rpcDefaultDatasource) { + return fmt.Errorf("NATS 未连接") + } + + queueRPCMu.Lock() + if queueRPCServices == nil { + queueRPCServices = make(map[string]map[string]rpcHandler) + } + if queueRPCSubs == nil { + queueRPCSubs = make(map[string]map[string]*nats.Subscription) + } + if queueRPCServices[queueName] == nil { + queueRPCServices[queueName] = make(map[string]rpcHandler) + } + if queueRPCSubs[queueName] == nil { + queueRPCSubs[queueName] = make(map[string]*nats.Subscription) + } + + // 如果已存在该服务,先取消之前的订阅 + if oldSub, exists := queueRPCSubs[queueName][serviceName]; exists { + oldSub.Unsubscribe() + } + + queueRPCServices[queueName][serviceName] = handler + queueRPCMu.Unlock() + + // 订阅服务主题(队列模式) + nc := getNatsConn(rpcDefaultDatasource) + if nc == nil { + return fmt.Errorf("NATS 连接不存在") + } + + subject := fmt.Sprintf("rpc.%s", serviceName) + sub, err := nc.QueueSubscribe(subject, queueName, func(msg *nats.Msg) { + // 执行处理函数 + executeHandler(handler, msg) + }) + + if err != nil { + return fmt.Errorf("注册队列 RPC 服务失败: %w", err) + } + + queueRPCMu.Lock() + queueRPCSubs[queueName][serviceName] = sub + queueRPCMu.Unlock() + + g.Log().Infof(context.Background(), "✅ 队列 RPC 服务已注册: %s (队列组: %s)", serviceName, queueName) + return nil +} + +// executeHandler 执行 RPC 处理函数 +func executeHandler(handler rpcHandler, msg *nats.Msg) { + // 响应 + var respData []byte + // 从消息头重建上下文 + ctx := headersToContext(context.Background(), msg.Header) + // 提取 TraceID,创建可取消的 context + ctx = createCancelContext(ctx, msg.Header.Get(traceIDKey)) + // 检查 context 是否已取消(在调用 handler 之前) + select { + case <-ctx.Done(): + // context 已取消,返回取消错误 + g.Log().Infof(ctx, "RPC 请求已取消,traceID: %s", msg.Header.Get(traceIDKey)) + // 仍然需要发送响应以避免客户端超时 + respData = []byte(`{"_err":"请求已取消"}`) + // 清理取消映射表 + cleanupTraceCancel(msg.Header.Get(traceIDKey)) + return + default: + } + + // 执行业务处理 + response, err := handler(ctx, msg.Data) + + if err != nil { + // 错误时返回 {"_err": "错误信息"} + if respData, err = json.Marshal(map[string]any{"_err": err.Error()}); err != nil { + g.Log().Errorf(ctx, "RPC 错误响应序列化失败: %v", err) + respData = []byte(`{"_err":"错误响应序列化失败"}`) + } + } else if response == nil { + // 空响应时返回空对象(或 {"_err": ""}) + respData = []byte(`{}`) + } else { + // 成功时返回业务数据 + if respData, err = json.Marshal(response); err != nil { + g.Log().Errorf(ctx, "RPC 响应序列化失败: %v", err) + respData = []byte(`{"_err":"响应序列化失败"}`) + } + } + // 发送响应(必须执行) 如果客户端用 nc.Request(...) 发送消息 → 双向模式,服务端必须 msg.Respond + if err = msg.Respond(respData); err != nil { + g.Log().Errorf(ctx, "RPC 响应失败: %v", err) + } + // 请求结束,清理取消映射表 + cleanupTraceCancel(msg.Header.Get(traceIDKey)) +} + +// createCancelContext 创建可取消的 context 并注册到取消映射表 +// 返回可取消的 context(如果 traceID 为空则返回原 context) +func createCancelContext(ctx context.Context, traceID string) context.Context { + if g.IsEmpty(traceID) { + return ctx + } + // 创建带取消功能的 context + taskCtx, cancel := context.WithCancel(ctx) + // 注册到取消映射表 + traceCancelMu.Lock() + if traceCancelMap == nil { + traceCancelMap = make(map[string]context.CancelFunc) + } + // 如果同一 TraceID 已有 CancelFunc,先调用它 + if oldCancel, exists := traceCancelMap[traceID]; exists { + oldCancel() + } + traceCancelMap[traceID] = cancel + traceCancelMu.Unlock() + + return taskCtx +} + +// ============ TraceID 主动取消功能 ============ +// 以下函数实现了基于 OpenTelemetry TraceID 的跨进程任务取消机制 + +// SetupCancelListener 设置取消监听器 +// 订阅取消主题,监听取消指令 +// 使用示例: +// +// sub, err := nats.SetupCancelListener(ctx) +func setupCancelListener(ctx context.Context) (*nats.Subscription, error) { + if !natsPing(ctx, rpcDefaultDatasource) { + return nil, fmt.Errorf("NATS 未连接") + } + + if traceCancelMap == nil { + traceCancelMap = make(map[string]context.CancelFunc) + } + + // 修复问题3:订阅取消主题,格式: ctx.cancel.otel.* + // 使用 * 通配符而不是 >,因为 TraceID 是最后一部分 + nc := getNatsConn(rpcDefaultDatasource) + if nc == nil { + return nil, fmt.Errorf("NATS 连接不存在") + } + + cancelSubject := cancelSubjectPrefix + "*" + sub, err := nc.Subscribe(cancelSubject, func(msg *nats.Msg) { + // 从主题中解析 TraceID (去除前缀) + prefixLen := len(cancelSubjectPrefix) + if len(msg.Subject) <= prefixLen { + g.Log().Warningf(ctx, "取消消息主题格式错误: %s", msg.Subject) + return + } + traceID := msg.Subject[prefixLen:] + + if traceID == "" { + g.Log().Warning(ctx, "取消消息主题缺少 TraceID") + return + } + + // 从映射表获取 CancelFunc 并执行取消 + traceCancelMu.RLock() + cancel, ok := traceCancelMap[traceID] + traceCancelMu.RUnlock() + + if ok { + cancel() + g.Log().Infof(ctx, "📢 取消信号已发送,traceID: %s", traceID) + } else { + g.Log().Infof(ctx, "⚠️ 未找到对应的可取消任务,traceID: %s", traceID) + } + }) + + if err != nil { + return nil, fmt.Errorf("设置取消监听器失败: %w", err) + } + + g.Log().Infof(ctx, "✅ 取消监听器已设置: %s", cancelSubject) + return sub, nil +} + +// publishCancel 发布取消指令 +// 向指定 TraceID 发送取消信号 +// 使用示例: +// +// err := nats.publishCancel(ctx, traceID) +func publishCancel(ctx context.Context, traceID string) error { + if !natsPing(ctx, rpcDefaultDatasource) { + return fmt.Errorf("NATS 未连接") + } + + if traceID == "" { + return fmt.Errorf("TraceID 不能为空") + } + + nc := getNatsConn(rpcDefaultDatasource) + if nc == nil { + return fmt.Errorf("NATS 连接不存在") + } + + cancelSubject := cancelSubjectPrefix + traceID + err := nc.Publish(cancelSubject, nil) + if err != nil { + return fmt.Errorf("发布取消信号失败: %w", err) + } + + g.Log().Infof(ctx, "📤 已发送取消信号,traceID: %s,主题: %s", traceID, cancelSubject) + return nil +} + +// cleanupTraceCancel 清理取消映射表中的条目 +// 任务取消/正常结束后必须调用此函数,避免内存泄漏 +// 使用示例: +// +// defer nats.cleanupTraceCancel(traceID) +func cleanupTraceCancel(traceID string) { + if traceID == "" { + return + } + + traceCancelMu.Lock() + defer traceCancelMu.Unlock() + + if _, ok := traceCancelMap[traceID]; ok { + delete(traceCancelMap, traceID) + g.Log().Infof(context.Background(), "✅ 已清理取消映射表,traceID: %s", traceID) + } +} + +// CallRPC 调用 RPC 服务 +// serviceName: 服务名称 +// req: 请求数据 +// 返回: 响应数据(任意类型)和错误 +func CallRPC(ctx context.Context, serviceName string, req any, resp any) (err error) { + if !natsPing(ctx, rpcDefaultDatasource) { + return fmt.Errorf("NATS 未连接") + } + + // 验证 resp 必须是指针类型 + respValue := reflect.ValueOf(resp) + if respValue.Kind() != reflect.Ptr { + return fmt.Errorf("resp 参数必须是指针类型(当前类型: %T)", resp) + } + + // 构建请求体 + var reqBody []byte + if !g.IsEmpty(req) { + reqValue := reflect.ValueOf(req) + if !(reqValue.Kind() == reflect.Ptr && reqValue.IsNil()) && !reqValue.IsZero() { + reqData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("序列化请求参数失败: %w", err) + } + reqBody = reqData + } + } + + // 检查本地是否有注册的单实例服务,如果有则直接调用(优化性能) + rpcServicesMu.RLock() + if localHandler, exists := rpcServices[serviceName]; exists { + rpcServicesMu.RUnlock() + + // 修复问题1:本地调用也需要处理取消机制 + var traceID string + if traceID, err = getTraceID(ctx); err != nil { + return err + } + // 提取 TraceID,创建可取消的 context + cancelCtx := createCancelContext(ctx, traceID) + // 执行本地调用 + var response interface{} + if response, err = localHandler(cancelCtx, reqBody); err != nil { + return fmt.Errorf("本地调用 RPC 服务失败 [%s]: %w", serviceName, err) + } + + // 请求结束,清理取消映射表 + cleanupTraceCancel(traceID) + + // 检查是否为错误消息:尝试解析为 map,看是否包含 "_err" 字段 + var respMap map[string]any + if json.Unmarshal(response.([]byte), &respMap) == nil { + if errMsg, ok := respMap["_err"]; ok { + return fmt.Errorf("%v", errMsg) + } + } + // 正常数据直接返回 + // responseMsg.Data 已经是 []byte 类型(来自 msg.Data),直接反序列化 + if err = json.Unmarshal(response.([]byte), resp); err != nil { + return fmt.Errorf("解析响应失败: %w (响应内容: %s)", err, response) + } + + return + } + rpcServicesMu.RUnlock() + + subject := fmt.Sprintf("rpc.%s", serviceName) + + // 创建消息并将上下文元数据写入消息头 + msg := nats.NewMsg(subject) + msg.Data = reqBody + headers, err := contextToHeaders(ctx) + if err != nil { + return fmt.Errorf("上下文转换失败: %w", err) + } + msg.Header = headers + + // 修复问题5:优化 go 协程避免资源泄漏 + // 使用 done channel 来确保 goroutine 能正确退出 + done := make(chan struct{}) + var closeDoneOnce sync.Once + closeDone := func() { + closeDoneOnce.Do(func() { + close(done) + }) + } + + if msg.Header.Get(traceIDKey) != "" { + go func() { + defer closeDone() + select { + case <-ctx.Done(): + // context 被取消时,发送取消信号给服务端 + if errors.Is(ctx.Err(), context.Canceled) { + if err := publishCancel(context.Background(), msg.Header.Get(traceIDKey)); err != nil { + g.Log().Errorf(ctx, "发送 RPC 取消信号失败: %v", err) + } else { + g.Log().Infof(ctx, "RPC 调用已取消,traceID: %s", msg.Header.Get(traceIDKey)) + } + } + case <-done: + // 请求已完成,无需发送取消信号 + return + } + }() + } + + // 发送请求 + nc := getNatsConn(rpcDefaultDatasource) + if nc == nil { + return fmt.Errorf("NATS 连接不存在") + } + + responseMsg, err := nc.RequestMsgWithContext(ctx, msg) + + // 关闭 done channel,通知 goroutine 退出 + closeDone() + + if err != nil { + return fmt.Errorf("调用 RPC 服务失败 [%s]: %w", serviceName, err) + } + + if responseMsg == nil { + return fmt.Errorf("RPC 响应为空 [%s]", serviceName) + } + + // 解析响应 + if len(responseMsg.Data) > 0 { + // 检查是否为错误消息:尝试解析为 map,看是否包含 "_err" 字段 + var respMap map[string]any + if json.Unmarshal(responseMsg.Data, &respMap) == nil { + if errMsg, ok := respMap["_err"]; ok { + return fmt.Errorf("%v", errMsg) + } + } + // 正常数据直接返回 + // responseMsg.Data 已经是 []byte 类型(来自 msg.Data),直接反序列化 + if err = json.Unmarshal(responseMsg.Data, resp); err != nil { + return fmt.Errorf("解析响应失败: %w (响应内容: %s)", err, responseMsg.Data) + } + } + + return +} + +// RegisterServiceOption 注册选项类型 +type registerServiceOption func(*registerServiceConfig) + +type registerServiceConfig struct { + queueName string // 队列组名(用于集群模式) + excludeMethods []string +} + +// WithQueueGroup 设置队列组名(集群模式) +func WithQueueGroup(queueName string) registerServiceOption { + return func(cfg *registerServiceConfig) { + cfg.queueName = queueName + } +} + +// WithExcludeMethods 排除不需要注册的方法 +func WithExcludeMethods(methods ...string) registerServiceOption { + return func(cfg *registerServiceConfig) { + cfg.excludeMethods = append(cfg.excludeMethods, methods...) + } +} + +// AutoRegisterServices 自动注册多个服务的所有公开方法 +// serviceInstances: map[包名]service实例,如 map[string]interface{}{"user": userService, "order": orderService} +// options: 注册选项(可选) +// 示例: +// +// AutoRegisterServices(map[string]interface{}{ +// "user": userService, +// "order": orderService, +// }) +// 或 +// AutoRegisterServices(map[string]interface{}{ +// "order": orderService, +// }, WithQueueGroup("order-group")) +func AutoRegisterServices(ctx context.Context, serviceInstances map[string]interface{}, options ...registerServiceOption) error { + // 先注册 RPC 服务(如果 NATS 不可用则记录警告但不阻塞启动) + if !natsPing(ctx, rpcDefaultDatasource) { + return fmt.Errorf("NATS 未连接,RPC 服务未注册") + } + + if len(serviceInstances) == 0 { + return fmt.Errorf("service 实例列表不能为空") + } + + totalRegistered := 0 + // 遍历每个 service 实例 + for pkgName, serviceInstance := range serviceInstances { + // 注册服务 + err := registerService(serviceInstance, pkgName, options...) + if err != nil { + g.Log().Errorf(ctx, "注册 %s 服务失败: %v", pkgName, err) + continue + } + totalRegistered++ + g.Log().Infof(ctx, "✅ %s 服务已自动注册", pkgName) + } + + if totalRegistered == 0 { + return fmt.Errorf("未能注册任何服务") + } + // 设置取消监听器(监听基于 TraceID 的取消请求) + if _, err := setupCancelListener(ctx); err != nil { + g.Log().Errorf(ctx, "设置取消监听器失败: %v", err) + } else { + g.Log().Infof(ctx, "✅ 取消监听器已自动设置") + } + g.Log().Infof(ctx, "✅ 共自动注册了 %d 个服务", totalRegistered) + + return nil +} + +// registerService 注册单个服务的所有公开方法(内部函数) +func registerService(service interface{}, serviceNamePrefix string, options ...registerServiceOption) (err error) { + if !natsPing(context.Background(), rpcDefaultDatasource) { + return fmt.Errorf("NATS 未连接") + } + + // 应用选项 + cfg := ®isterServiceConfig{} + for _, opt := range options { + opt(cfg) + } + + // 创建排除方法集合 + excludeSet := make(map[string]struct{}) + for _, method := range cfg.excludeMethods { + excludeSet[method] = struct{}{} + } + + // 获取 service 的类型 + serviceType := reflect.TypeOf(service) + + // 遍历所有方法 + registeredCount := 0 + for i := 0; i < serviceType.NumMethod(); i++ { + method := serviceType.Method(i) + + // 只注册导出方法(首字母大写) + if !method.IsExported() { + continue + } + + // 排除指定的方法 + if _, exists := excludeSet[method.Name]; exists { + continue + } + + // 检查方法签名:必须是 func(ctx context.Context, request) (response, error) + // 注意:method.Type.NumIn() 包含接收者,所以实际参数数量需要减去 1 + // 要求:接收者 + context.Context + request,总共3个参数 + if method.Type.NumIn() != 3 { + g.Log().Warningf(context.Background(), "方法 %s 必须有2个参数(context.Context 和请求参数),跳过注册", method.Name) + continue + } + + // 第一个参数(接收者之后的第一个参数)必须是 context.Context + // method.Type.In(0) 是接收者,method.Type.In(1) 才是第一个参数 + if !method.Type.In(1).Implements(reflect.TypeOf((*context.Context)(nil)).Elem()) { + g.Log().Warningf(context.Background(), "方法 %s 的第一个参数必须是 context.Context,跳过注册", method.Name) + continue + } + + // 第二个参数必须是结构体指针或数组 + reqType := method.Type.In(2) + if reqType.Kind() != reflect.Ptr && reqType.Kind() != reflect.Slice && reqType.Kind() != reflect.Array { + g.Log().Warningf(context.Background(), "方法 %s 的第二个参数必须是结构体指针或数组,跳过注册", method.Name) + continue + } + + // 返回值必须是 (result, error),即2个返回值 + if method.Type.NumOut() != 2 { + g.Log().Warningf(context.Background(), "方法 %s 必须有2个返回值(result 和 error),跳过注册", method.Name) + continue + } + + // 最后一个返回值必须是 error + if !method.Type.Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { + g.Log().Warningf(context.Background(), "方法 %s 的最后一个返回值必须是 error,跳过注册", method.Name) + continue + } + + // 生成服务名称:前缀.方法名(保持原始方法名) + serviceName := fmt.Sprintf("%s.%s", serviceNamePrefix, method.Name) + + // 创建 RPC handler + handler := func(ctx context.Context, req []byte) (any, error) { + // 准备方法调用参数 + // args[0] 是接收者, args[1] 是 ctx, args[2] 是请求参数 + args := make([]reflect.Value, 3) + args[0] = reflect.ValueOf(service) // 接收者 + args[1] = reflect.ValueOf(ctx) // context.Context + + // 解析请求参数 + if len(req) > 0 { + reqValuePtr := reflect.New(reqType) + + // 解析 JSON + if err := json.Unmarshal(req, reqValuePtr.Interface()); err != nil { + // 根据参数类型提供更友好的错误提示 + var typeHint string + if reqType.Kind() == reflect.Ptr { + typeHint = fmt.Sprintf("(期望类型: %s)", reqType.Elem().Name()) + } else { // reflect.Slice 或 reflect.Array + typeHint = fmt.Sprintf("(期望类型: %s,请确保客户端传递的是JSON数组格式)", reqType.String()) + } + return nil, fmt.Errorf("解析请求参数失败%s: %w", typeHint, err) + } + args[2] = reqValuePtr.Elem() + } else { + // 请求为空,创建零值 + args[2] = reflect.Zero(method.Type.In(2)) + } + + // 调用方法 + results := method.Func.Call(args) + + // 处理返回值 + var result any + + if len(results) == 1 { + // 只有 error + if !results[0].IsNil() { + err = results[0].Interface().(error) + } + } else if len(results) == 2 { + // (result, error) + result = results[0].Interface() + if !results[1].IsNil() { + err = results[1].Interface().(error) + } + } + if err != nil { + return nil, err + } + + return result, nil + } + + // 注册 RPC 服务 + var err error + if cfg.queueName != "" { + err = registerQueueRPCService(serviceName, cfg.queueName, handler) + } else { + err = registerRPCService(serviceName, handler) + } + + if err != nil { + g.Log().Errorf(context.Background(), "注册服务 %s 失败: %v", serviceName, err) + continue + } + + registeredCount++ + g.Log().Infof(context.Background(), "✅ 已自动注册 RPC 服务: %s -> %s", serviceName, method.Name) + } + + if registeredCount == 0 { + g.Log().Warningf(context.Background(), "未注册任何方法,请检查 %v 的方法签名", serviceNamePrefix) + return fmt.Errorf("未找到可注册的方法") + } + + g.Log().Infof(context.Background(), "✅ Service %v 共注册了 %d 个 RPC 方法", serviceNamePrefix, registeredCount) + return nil +} + +// ============ 上下文元数据工具函数 ============ +// 以下函数用于在 context 和 NATS 消息头之间互转元数据 + +// 定义常见的上下文元数据 key(私有) +const ( + traceIDKey = "trace_id" + tokenKey = "token" +) + +func getTraceID(ctx context.Context) (traceID string, err error) { + // 提取 traceId:首先尝试从 OpenTelemetry Span 中提取,从 context 中提取 TraceID + span := trace.SpanFromContext(ctx) + if span != nil && span.SpanContext().HasTraceID() { + traceID = span.SpanContext().TraceID().String() + } else if tid := ctx.Value(traceIDKey); tid != nil { + traceID = fmt.Sprintf("%v", tid) + } + if traceID == "" { + return traceID, fmt.Errorf("context 中没有 TraceID") + } + return +} + +// contextToHeaders 将 context 中的元数据转换为 NATS 消息头 +// 支持提取 user_id、tenant_id、trace_id、token 等常见字段 +func contextToHeaders(ctx context.Context) (nats.Header, error) { + headers := make(nats.Header) + + // 提取 traceId:首先尝试从 OpenTelemetry Span 中提取 + if traceID, err := getTraceID(ctx); err != nil { + return headers, err + } else { + headers.Set(traceIDKey, traceID) + } + + // 提取 token(优先级:context value > HTTP Authorization header) + token := "" + if t := ctx.Value(tokenKey); t != nil { + token = fmt.Sprintf("%v", t) + } else if r := g.RequestFromCtx(ctx); r != nil { + // 从 HTTP 请求的 Authorization header 中提取 token + auth := r.GetHeader("Authorization") + if auth != "" { + // 移除 "Bearer " 前缀 + if len(auth) > 7 && auth[:7] == "Bearer " { + token = auth[7:] + } else { + token = auth + } + } + } + if token != "" { + headers.Set(tokenKey, token) + } + + return headers, nil +} + +// headersToContext 从 NATS 消息头重建 context +// 支持还原 user_id、tenant_id、trace_id、token 等字段 +func headersToContext(ctx context.Context, headers nats.Header) context.Context { + if headers == nil { + return ctx + } + + // 恢复 trace_id + if traceID := headers.Get(traceIDKey); traceID != "" { + ctx = context.WithValue(ctx, traceIDKey, traceID) + } + + // 恢复 token + if token := headers.Get(tokenKey); token != "" { + ctx = context.WithValue(ctx, tokenKey, token) + } + + return ctx +} diff --git a/message/rabbitmq_msg.go b/message/rabbitmq_msg.go new file mode 100644 index 0000000..3eab2a8 --- /dev/null +++ b/message/rabbitmq_msg.go @@ -0,0 +1,311 @@ +package message + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/gogf/gf/v2/frame/g" + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitMQPublishMsgConfig struct { + QueueName string + Durable bool + Data any +} + +type RabbitMQPublishDelayMsgConfig struct { + QueueName string + Durable bool + DelayTime int + Data any +} + +type RabbitMQSubscribeMsgConfig struct { + QueueName string + ConsumerName string + AutoAck bool + PrefetchCount int + HandleFunc func(ctx context.Context, message map[string]interface{}) error +} + +func (*RabbitMQPublishMsgConfig) GetPublishMsgType() { + +} + +func (*RabbitMQPublishDelayMsgConfig) GetPublishDelayMsgType() {} + +func (*RabbitMQSubscribeMsgConfig) GetSubscribeMsgType() { + +} + +type rabbitMQ struct { + name string // 数据源名称 +} + +func init() { + // 注册 RabbitMQ 插件(默认数据源) + RegisterPlugin(context.Background(), "default", MessageRabbitMQ, func() messageUtil { + return &rabbitMQ{name: "default"} + }) +} + +// Connect 连接 RabbitMQ +func (c *rabbitMQ) Connect(ctx context.Context) error { + return rabbitmqConnect(ctx, c.name) +} + +// Ping 检测 RabbitMQ 连接状态 +func (c *rabbitMQ) Ping(ctx context.Context) bool { + return rabbitmqPing(ctx, c.name) +} + +// Close 关闭 RabbitMQ 连接 +func (c *rabbitMQ) Close(ctx context.Context) error { + return rabbitmqClose(ctx, c.name) +} + +// Publish 发布消息 +func (c *rabbitMQ) Publish(ctx context.Context, msgConfig messagePublishConfig) error { + cfg, ok := msgConfig.(*RabbitMQPublishMsgConfig) + if !ok { + return fmt.Errorf("无效的 RabbitMQ 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("队列名称不能为空") + } + if cfg.Data == nil { + return fmt.Errorf("数据不能为空") + } + return c.publishMessageInternal(ctx, cfg.QueueName, cfg.Durable, 0, cfg.Data) +} + +// PublishDelay 发布延迟消息 +func (c *rabbitMQ) PublishDelay(ctx context.Context, msgConfig messagePublishDelayConfig) error { + cfg, ok := msgConfig.(*RabbitMQPublishDelayMsgConfig) + if !ok { + return fmt.Errorf("无效的 RabbitMQ 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("队列名称不能为空") + } + if cfg.Data == nil { + return fmt.Errorf("数据不能为空") + } + return c.publishMessageInternal(ctx, cfg.QueueName, cfg.Durable, cfg.DelayTime, cfg.Data) +} + +// publishMessage 发布消息内部实现 +func (c *rabbitMQ) publishMessageInternal(ctx context.Context, queueName string, durable bool, delayTime int, data interface{}) error { + if !c.Ping(ctx) { + if err := commonConnect(ctx, MessageRabbitMQ, c.name, func(ctx context.Context) error { + return c.Connect(ctx) + }, func(ctx context.Context) error { + return c.Close(ctx) + }); err != nil { + g.Log().Errorf(ctx, "❌ [%s][%s] 连接失败: %v", MessageRabbitMQ, c.name, err) + return err + } + } + + channel := getRabbitMQChannel(c.name) + if channel == nil || channel.IsClosed() { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] Channel 不存在或已关闭", c.name) + return fmt.Errorf("RabbitMQ Channel 不存在或已关闭") + } + + delayMsg := delayTime > 0 + + // 1. 决定 Exchange 类型 + exchangeType := "fanout" + exchangeName := queueName + routingKey := queueName + args := amqp.Table{} + if delayMsg { + exchangeType = "x-delayed-message" + exchangeName = queueName + ".delayed" + args["x-delayed-type"] = "fanout" + } + + // 2. 声明 Exchange(使用 exchangeName 而不是 queueName) + if err := channel.ExchangeDeclare( + exchangeName, // 修复:使用正确的交换机名称 + exchangeType, + durable, + false, // autoDelete + false, // internal + false, // noWait + args, + ); err != nil { + g.Log().Errorf(ctx, "❌ 声明 Exchange 失败: %v", err) + return err + } + + // 3. 声明队列 + if _, err := channel.QueueDeclare( + queueName, + durable, + false, // autoDelete + false, // exclusive + false, // noWait + nil, // args + ); err != nil { + g.Log().Errorf(ctx, "❌ 声明队列失败: %v", err) + return err + } + + // 4. 绑定队列 + if err := channel.QueueBind( + queueName, + routingKey, // routingKey 路由键 + exchangeName, // exchange 交换机名称 + false, // noWait + nil, // args + ); err != nil { + g.Log().Errorf(ctx, "❌ 绑定队列失败: %v", err) + return err + } + + // 5. 序列化数据 + body, err := json.Marshal(data) + if err != nil { + g.Log().Errorf(ctx, "❌ 序列化数据失败: %v", err) + return err + } + // 6. 发布消息 + deliveryMode := amqp.Transient + if durable { + deliveryMode = amqp.Persistent + } + publishing := amqp.Publishing{ + ContentType: "application/json", + Body: body, + DeliveryMode: deliveryMode, + Timestamp: time.Now(), + } + if delayMsg { + duration := delayTime * 1000 // 延迟时间(毫秒)= 秒 * 1000 + publishing.Headers = amqp.Table{ + "x-delay": duration, + } + } + err = channel.PublishWithContext( + ctx, + exchangeName, + routingKey, + false, false, + publishing, + ) + if err != nil { + g.Log().Errorf(ctx, "❌ 发布消息失败: %v", err) + return err + } + g.Log().Infof(ctx, "📨 发布消息成功: queueName=%s, data=%v", queueName, data) + return err +} + +// Subscribe 订阅消息 +func (c *rabbitMQ) Subscribe(ctx context.Context, msgConfig messageSubscribeConfig) error { + cfg, ok := msgConfig.(*RabbitMQSubscribeMsgConfig) + if !ok { + return fmt.Errorf("无效的 RabbitMQ 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("队列名称不能为空") + } + if g.IsEmpty(cfg.ConsumerName) { + return fmt.Errorf("消费者名称不能为空") + } + if g.IsEmpty(cfg.PrefetchCount) { + cfg.PrefetchCount = 1 + } + if g.IsEmpty(cfg.HandleFunc) { + return fmt.Errorf("必须提供处理函数") + } + return c.createSubscribeInternal(ctx, cfg.QueueName, cfg.ConsumerName, cfg.PrefetchCount, cfg.AutoAck, cfg.HandleFunc) +} + +// createSubscribe 内部订阅消息 +func (c *rabbitMQ) createSubscribeInternal(ctx context.Context, queueName, consumerName string, prefetchCount int, autoAck bool, handler func(ctx context.Context, message map[string]interface{}) error) error { + g.Log().Infof(ctx, "🔔 RabbitMQ [%s] 开始订阅: queueName=%s, consumerName=%s", c.name, queueName, consumerName) + + if !c.Ping(ctx) { + if err := commonConnect(ctx, MessageRabbitMQ, c.name, func(ctx context.Context) error { + return c.Connect(ctx) + }, func(ctx context.Context) error { + return c.Close(ctx) + }); err != nil { + g.Log().Errorf(ctx, "❌ [%s][%s] 连接失败: %v", MessageRabbitMQ, c.name, err) + return err + } + } + + channel := getRabbitMQChannel(c.name) + if channel == nil || channel.IsClosed() { + g.Log().Errorf(ctx, "❌ RabbitMQ [%s] Channel 不存在或已关闭", c.name) + return fmt.Errorf("RabbitMQ Channel 不存在或已关闭") + } + + if err := channel.Qos(prefetchCount, 0, false); err != nil { + g.Log().Errorf(ctx, "❌ 设置 Qos 失败: %v", err) + return err + } + g.Log().Infof(ctx, "📊 设置 Prefetch Count: %d", prefetchCount) + + msg, err := channel.Consume( + queueName, // queue + consumerName, // consumer + autoAck, // auto-ack (根据配置决定) + false, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + if err != nil { + g.Log().Errorf(ctx, "❌ 消费消息失败: %v", err) + return err + } + g.Log().Infof(ctx, "👀 开始监听消息") + for { + select { + case <-ctx.Done(): + // Context 取消,退出 + g.Log().Infof(ctx, "context cancel 监听消息退出") + return nil + case m, ok := <-msg: + if !ok { + // Channel 关闭,退出 + g.Log().Infof(ctx, "channel close 监听消息退出") + return nil + } + g.Log().Infof(ctx, "📨 收到消息: %s", string(m.Body)) + + var data map[string]interface{} + if err := json.Unmarshal(m.Body, &data); err != nil { + // 如果不是 JSON,直接使用原始内容 + data = map[string]interface{}{ + "data": string(m.Body), + } + } + err := handler(ctx, data) + if err != nil { + g.Log().Errorf(ctx, "❌ 消息处理失败: %v", err) + // 仅在手动 ACK 模式下拒绝消息 + if !autoAck { + // 拒绝消息不再重新入队(避免死循环) + m.Nack(false, false) + continue + } + } + g.Log().Infof(ctx, "✅ 消息处理成功: %v", err) + // 仅在手动 ACK 模式下确认消息 + if err := m.Ack(false); err != nil { + g.Log().Errorf(ctx, "❌ AUTO ACK 消息失败: %v", err) + } else { + g.Log().Infof(ctx, "✅ AUTO ACK 消息成功") + } + } + } +} diff --git a/message/reconnect.go b/message/reconnect.go new file mode 100644 index 0000000..f042397 --- /dev/null +++ b/message/reconnect.go @@ -0,0 +1,73 @@ +package message + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// connectFunc 连接函数类型 +type connectFunc func(ctx context.Context) error + +// closeFunc 关闭函数类型 +type closeFunc func(ctx context.Context) error + +// reconnectOption 重连选项 +type reconnectOption struct { + maxRetries int // 最大重试次数,0 表示无限重试 + interval time.Duration // 重试间隔 + componentType messageType // 组件类型(nats/redis/rabbitmq) + componentName string // 组件名称(数据源名称) +} + +// defaultReconnectOption 默认重连选项 +func defaultReconnectOption(componentType messageType, componentName string) *reconnectOption { + return &reconnectOption{ + maxRetries: 0, // 无限重试 + interval: 3 * time.Second, + componentType: componentType, + componentName: componentName, + } +} + +// commonReconnect 重连函数(NATS、Redis、RabbitMQ 共用) +func commonReconnect(ctx context.Context, connectFn connectFunc, closeFn closeFunc, opt *reconnectOption) error { + if opt == nil { + opt = defaultReconnectOption("unknown", "default") + } + + for attempt := 0; opt.maxRetries == 0 || attempt < opt.maxRetries; attempt++ { + err := connectFn(ctx) + if err == nil { + g.Log().Infof(ctx, "✅ 连接成功: type=%s, name=%s, attempt=%d", + opt.componentType, opt.componentName, attempt+1) + return nil + } + // 记录失败日志 + g.Log().Warningf(ctx, "⚠️ 连接失败: type=%s, name=%s, attempt=%d, err=%v, 重试中...", + opt.componentType, opt.componentName, attempt+1, err) + // 如果错误信息中包含 "does not exist",则认为是连接失败,不再重试 + if strings.Contains(err.Error(), "does not exist") { + return err + } + // 等待一段时间再重试 + select { + case <-time.After(opt.interval): + case <-ctx.Done(): + if err = closeFn(ctx); err != nil { + return err + } + return ctx.Err() + } + } + return fmt.Errorf("连接失败,已达最大重试次数") +} + +// connect 连接函数,直接调用 commonReconnect +func commonConnect(ctx context.Context, componentType messageType, name string, connectFn func(ctx context.Context) error, closeFn closeFunc) error { + opt := defaultReconnectOption(componentType, name) + return commonReconnect(ctx, connectFn, closeFn, opt) +} diff --git a/message/redis_msg.go b/message/redis_msg.go new file mode 100644 index 0000000..343acb9 --- /dev/null +++ b/message/redis_msg.go @@ -0,0 +1,279 @@ +package message + +import ( + "context" + "fmt" + "github.com/gogf/gf/v2/os/glog" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +type RedisPublishMsgConfig struct { + QueueName string + Data any +} + +type RedisPublishDelayMsgConfig struct { +} + +type RedisSubscribeMsgConfig struct { + QueueName string + ConsumerName string + AutoAck bool + PrefetchCount int + HandleFunc func(ctx context.Context, message map[string]interface{}) error +} + +func (*RedisPublishMsgConfig) GetPublishMsgType() { + +} + +func (*RedisPublishDelayMsgConfig) GetPublishDelayMsgType() {} + +func (*RedisSubscribeMsgConfig) GetSubscribeMsgType() { + +} + +type redis struct { + name string // 数据源名称 +} + +func init() { + // 注册 Redis 插件(默认数据源) + RegisterPlugin(context.Background(), "default", MessageRedis, func() messageUtil { + return &redis{name: "default"} + }) +} + +// RedisStreamMessage Redis Stream 消息结构 +type redisStreamMessage struct { + ID string + Values map[string]interface{} +} + +// Connect 连接 Redis +func (c *redis) Connect(ctx context.Context) error { + return redisConnect(ctx, c.name) +} + +// Ping 检测 Redis 连接状态 +func (c *redis) Ping(ctx context.Context) bool { + return redisPing(ctx, c.name) +} + +// Close 关闭 Redis 连接 +func (c *redis) Close(ctx context.Context) error { + return redisClose(ctx, c.name) +} + +// Publish 发布消息 +func (c *redis) Publish(ctx context.Context, msgConfig messagePublishConfig) error { + cfg, ok := msgConfig.(*RedisPublishMsgConfig) + if !ok { + return fmt.Errorf("无效的 Redis 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("队列名称不能为空") + } + if g.IsEmpty(cfg.Data) { + return fmt.Errorf("数据不能为空") + } + + rc := getRedisConn(c.name) + if !c.Ping(ctx) { + if err := commonConnect(ctx, MessageRedis, c.name, func(ctx context.Context) error { + return c.Connect(ctx) + }, func(ctx context.Context) error { + return c.Close(ctx) + }); err != nil { + g.Log().Errorf(ctx, "❌ [%s][%s] 连接失败: %v", MessageRedis, c.name, err) + return err + } + } + + values := gconv.Map(cfg.Data) + args := make([]interface{}, 0, len(values)*2+2) + args = append(args, cfg.QueueName, "*") + for key, val := range values { + args = append(args, key, val) + } + result, err := rc.Do(ctx, "XADD", args...) + if err != nil { + g.Log().Errorf(ctx, "❌ Redis 发布消息失败: key=%s, err=%v", cfg.QueueName, err) + return err + } + g.Log().Infof(ctx, "✅ Redis 发布消息成功: key=%s, messageID=%s", cfg.QueueName, gconv.String(result)) + return nil +} + +// PublishDelay 发布延迟消息 +func (c *redis) PublishDelay(ctx context.Context, _ messagePublishDelayConfig) error { + g.Log().Errorf(ctx, "❌ Redis 不支持延迟消息") + return fmt.Errorf("❌ Redis 不支持延迟消息") +} + +// Subscribe 订阅消息 +func (c *redis) Subscribe(ctx context.Context, msgConfig messageSubscribeConfig) error { + cfg, ok := msgConfig.(*RedisSubscribeMsgConfig) + if !ok { + return fmt.Errorf("无效的 Redis 配置类型") + } + if g.IsEmpty(cfg.QueueName) { + return fmt.Errorf("队列名称不能为空") + } + if g.IsEmpty(cfg.ConsumerName) { + return fmt.Errorf("消费者名称不能为空") + } + if g.IsEmpty(cfg.HandleFunc) { + return fmt.Errorf("处理函数不能为空") + } + return c.createSubscribe(ctx, cfg.QueueName, cfg.ConsumerName, cfg.PrefetchCount, cfg.AutoAck, cfg.HandleFunc) +} + +// createSubscribe 内部订阅消息 +func (c *redis) createSubscribe(ctx context.Context, key, consumerName string, prefetchCount int, autoAck bool, handler func(ctx context.Context, message map[string]interface{}) error) error { + +LOOP: + err := c.consumeMessages(ctx, key, consumerName, prefetchCount, autoAck, handler) + if err != nil { + // 对于超时错误,返回nil继续循环,而不是返回错误 + if strings.Contains(err.Error(), "i/o timeout") || strings.Contains(err.Error(), "timeout") || + strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "context canceled") { + + time.Sleep(time.Second) + goto LOOP + } else { + g.Log().Errorf(ctx, "❌ 严重错误: %v", err) + } + } + time.Sleep(time.Second) + goto LOOP +} + +// consumeMessages 消费消息 +func (c *redis) consumeMessages(ctx context.Context, key, consumerName string, prefetchCount int, autoAck bool, handler func(ctx context.Context, message map[string]interface{}) error) error { + if !c.Ping(ctx) { + if err := commonConnect(ctx, MessageRedis, c.name, func(ctx context.Context) error { + return c.Connect(ctx) + }, func(ctx context.Context) error { + return c.Close(ctx) + }); err != nil { + g.Log().Errorf(ctx, "❌ [%s][%s] 连接失败: %v", MessageRedis, c.name, err) + return err + } + } + + rc := getRedisConn(c.name) + if rc == nil { + g.Log().Errorf(ctx, "❌ Redis [%s] 连接不存在", c.name) + return fmt.Errorf("Redis 连接不存在") + } + + // 检查消费者组是否存在 + groupName := "default" + _, err := rc.Do(ctx, "XGROUP", "CREATE", key, groupName, "0", "MKSTREAM") + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "BUSYGROUP") && strings.Contains(errStr, "already exists") { + glog.Infof(ctx, "✅ Redis [%s] 消费者组已存在: %s", c.name, key) + return nil + } + g.Log().Errorf(ctx, "❌ 创建消费组失败: key=%s, err=%v", key, err) + return err + } + glog.Infof(ctx, "✅ Redis [%s] 消费者组创建成功: %s", c.name, key) + + // 使用带重试的命令执行 + result, err := rc.Do(ctx, "XREADGROUP", "GROUP", groupName, consumerName, "COUNT", prefetchCount, "BLOCK", 0, "STREAMS", key, ">") + if err != nil { + return err + } + messages, err := c.parseStreamResult(result) + if err != nil { + g.Log().Errorf(ctx, "❌ 解析消息失败: %v", err) + return err + } + for _, msg := range messages { + // 处理消息 + if err := handler(ctx, msg.Values); err != nil { + g.Log().Errorf(ctx, "❌ 消息处理失败: messageID=%s, err=%v", msg.ID, err) + // 如果不是自动ACK,则跳过当前消息 + if !autoAck { + continue + } + } else { + g.Log().Infof(ctx, "✅ 消息处理成功: messageID=%s", msg.ID) + } + // ACK 消息 + args := make([]interface{}, 0, len(msg.ID)+2) + args = append(args, key, groupName, msg.ID) + _, err = rc.Do(ctx, "XACK", args...) + if err != nil { + g.Log().Errorf(ctx, "❌ ACK 消息失败: messageID=%s, err=%v", msg.ID, err) + } else { + g.Log().Infof(ctx, "✅ ACK 消息成功: messageID=%s", msg.ID) + } + } + + return nil +} + +// parseStreamResult 解析 Stream 结果 +func (c *redis) parseStreamResult(result interface{}) ([]redisStreamMessage, error) { + if result == nil { + return []redisStreamMessage{}, nil + } + + var resultVal interface{} + + // 尝试获取 Val() 方法 + if valuer, ok := result.(interface{ Val() interface{} }); ok { + resultVal = valuer.Val() + } else { + resultVal = result + } + + // 检查是否为空 + if resultVal == nil { + return []redisStreamMessage{}, nil + } + + // 预分配切片容量,避免多次扩容 + messages := make([]redisStreamMessage, 0) + + if streamsMap, ok := resultVal.(map[interface{}]interface{}); ok { + for _, streamData := range streamsMap { + msgArray, ok := streamData.([]interface{}) + if !ok { + continue + } + for _, msgData := range msgArray { + msgArray, ok := msgData.([]interface{}) + if !ok || len(msgArray) < 2 { + continue + } + msgID := gconv.String(msgArray[0]) + fieldsArray, ok := msgArray[1].([]interface{}) + if !ok { + continue + } + values := make(map[string]interface{}, len(fieldsArray)/2) + for i := 0; i < len(fieldsArray); i += 2 { + if i+1 < len(fieldsArray) { + key := gconv.String(fieldsArray[i]) + values[key] = fieldsArray[i+1] + } + } + messages = append(messages, redisStreamMessage{ + ID: msgID, + Values: values, + }) + } + } + } + + return messages, nil +} diff --git a/message/store.go b/message/store.go new file mode 100644 index 0000000..15f8bc5 --- /dev/null +++ b/message/store.go @@ -0,0 +1,125 @@ +// Copyright 2019-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package message + +import "fmt" + +type RetentionPolicy int + +const ( + // LimitsPolicy (default) means that messages are retained until any given limit is reached. + // This could be one of MaxMsgs, MaxBytes, or MaxAge. + LimitsPolicy RetentionPolicy = iota + // InterestPolicy specifies that when all known consumers have acknowledged a message it can be removed. + InterestPolicy + // WorkQueuePolicy specifies that when the first worker or subscriber acknowledges the message it can be removed. + WorkQueuePolicy +) + +// MarshalJSON 将 RetentionPolicy 序列化为字符串 +func (rp RetentionPolicy) MarshalJSON() ([]byte, error) { + switch rp { + case LimitsPolicy: + return []byte(`"limits"`), nil + case InterestPolicy: + return []byte(`"interest"`), nil + case WorkQueuePolicy: + return []byte(`"workqueue"`), nil + default: + return nil, fmt.Errorf("can not marshal %v", rp) + } +} + +// UnmarshalJSON 将字符串反序列化为 RetentionPolicy +func (rp *RetentionPolicy) UnmarshalJSON(data []byte) error { + switch string(data) { + case `"limits"`: + *rp = LimitsPolicy + case `"interest"`: + *rp = InterestPolicy + case `"workqueue"`: + *rp = WorkQueuePolicy + default: + return fmt.Errorf("unknown retention policy: %s", string(data)) + } + return nil +} + +type DiscardPolicy int + +const ( + // DiscardOld will remove older messages to return to the limits. + DiscardOld = iota + // DiscardNew will error on a StoreMsg call + DiscardNew +) + +// MarshalJSON 将 DiscardPolicy 序列化为字符串 +func (dp DiscardPolicy) MarshalJSON() ([]byte, error) { + switch dp { + case DiscardOld: + return []byte(`"old"`), nil + case DiscardNew: + return []byte(`"new"`), nil + default: + return nil, fmt.Errorf("can not marshal %v", dp) + } +} + +// UnmarshalJSON 将字符串反序列化为 DiscardPolicy +func (dp *DiscardPolicy) UnmarshalJSON(data []byte) error { + switch string(data) { + case `"old"`: + *dp = DiscardOld + case `"new"`: + *dp = DiscardNew + default: + return fmt.Errorf("unknown discard policy: %s", string(data)) + } + return nil +} + +type StorageType int + +const ( + // FileStorage specifies on disk, designated by the JetStream config StoreDir. + FileStorage = StorageType(22) + // MemoryStorage specifies in memory only. + MemoryStorage = StorageType(33) +) + +// MarshalJSON 将 StorageType 序列化为字符串 +func (st StorageType) MarshalJSON() ([]byte, error) { + switch st { + case MemoryStorage: + return []byte(`"memory"`), nil + case FileStorage: + return []byte(`"file"`), nil + default: + return nil, fmt.Errorf("can not marshal %v", st) + } +} + +// UnmarshalJSON 将字符串反序列化为 StorageType +func (st *StorageType) UnmarshalJSON(data []byte) error { + switch string(data) { + case `"memory"`: + *st = MemoryStorage + case `"file"`: + *st = FileStorage + default: + return fmt.Errorf("unknown storage type: %s", string(data)) + } + return nil +} diff --git a/message/stream.go b/message/stream.go new file mode 100644 index 0000000..4f2a7c1 --- /dev/null +++ b/message/stream.go @@ -0,0 +1,212 @@ +// Copyright 2019-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package message + +import ( + "fmt" + "time" +) + +// StreamConfig will determine the name, subjects and retention policy +// for a given stream. If subjects is empty the name will be used. +type StreamConfig struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Subjects []string `json:"subjects,omitempty"` + Retention RetentionPolicy `json:"retention"` + MaxConsumers int `json:"max_consumers"` + MaxMsgs int64 `json:"max_msgs"` + MaxBytes int64 `json:"max_bytes"` + MaxAge time.Duration `json:"max_age"` + MaxMsgsPer int64 `json:"max_msgs_per_subject"` + MaxMsgSize int32 `json:"max_msg_size,omitempty"` + Discard DiscardPolicy `json:"discard"` + Storage StorageType `json:"storage"` + Replicas int `json:"num_replicas"` + NoAck bool `json:"no_ack,omitempty"` + Duplicates time.Duration `json:"duplicate_window,omitempty"` + Placement *Placement `json:"placement,omitempty"` + Mirror *StreamSource `json:"mirror,omitempty"` + Sources []*StreamSource `json:"sources,omitempty"` + Compression StoreCompression `json:"compression"` + FirstSeq uint64 `json:"first_seq,omitempty"` + + // Allow applying a subject transform to incoming messages before doing anything else + SubjectTransform *SubjectTransformConfig `json:"subject_transform,omitempty"` + + // Allow republish of the message after being sequenced and stored. + RePublish *RePublish `json:"republish,omitempty"` + + // Allow higher performance, direct access to get individual messages. E.g. KeyValue + AllowDirect bool `json:"allow_direct"` + // Allow higher performance and unified direct access for mirrors as well. + MirrorDirect bool `json:"mirror_direct"` + + // Allow KV like semantics to also discard new on a per subject basis + DiscardNewPer bool `json:"discard_new_per_subject,omitempty"` + + // Optional qualifiers. These can not be modified after set to true. + + // Sealed will seal a stream so no messages can get out or in. + Sealed bool `json:"sealed"` + // DenyDelete will restrict the ability to delete messages. + DenyDelete bool `json:"deny_delete"` + // DenyPurge will restrict the ability to purge messages. + DenyPurge bool `json:"deny_purge"` + // AllowRollup allows messages to be placed into the system and purge + // all older messages using a special msg header. + AllowRollup bool `json:"allow_rollup_hdrs"` + + // The following defaults will apply to consumers when created against + // this stream, unless overridden manually. + // TODO(nat): Can/should we name these better? + ConsumerLimits StreamConsumerLimits `json:"consumer_limits"` + + // AllowMsgTTL allows header initiated per-message TTLs. If disabled, + // then the `NATS-TTL` header will be ignored. + AllowMsgTTL bool `json:"allow_msg_ttl"` + + // SubjectDeleteMarkerTTL sets the TTL of delete marker messages left behind by + // subject delete markers. + SubjectDeleteMarkerTTL time.Duration `json:"subject_delete_marker_ttl,omitempty"` + + // AllowMsgCounter allows a stream to use (only) counter CRDTs. + AllowMsgCounter bool `json:"allow_msg_counter,omitempty"` + + // AllowAtomicPublish allows atomic batch publishing into the stream. + AllowAtomicPublish bool `json:"allow_atomic,omitempty"` + + // AllowMsgSchedules allows the scheduling of messages. + AllowMsgSchedules bool `json:"allow_msg_schedules,omitempty"` + + // PersistMode allows to opt-in to different persistence mode settings. + PersistMode PersistModeType `json:"persist_mode,omitempty"` + + // Metadata is additional metadata for the Stream. + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Used to guide placement of streams and meta controllers in clustered JetStream. +type Placement struct { + Cluster string `json:"cluster,omitempty"` + Tags []string `json:"tags,omitempty"` + Preferred string `json:"preferred,omitempty"` +} + +// StreamSource dictates how streams can source from other streams. +type StreamSource struct { + Name string `json:"name"` + OptStartSeq uint64 `json:"opt_start_seq,omitempty"` + OptStartTime *time.Time `json:"opt_start_time,omitempty"` + FilterSubject string `json:"filter_subject,omitempty"` + SubjectTransforms []SubjectTransformConfig `json:"subject_transforms,omitempty"` + External *ExternalStream `json:"external,omitempty"` + + // Internal + iname string // For indexing when stream names are the same for multiple sources. +} + +// SubjectTransformConfig is for applying a subject transform (to matching messages) before doing anything else when a new message is received +type SubjectTransformConfig struct { + Source string `json:"src"` + Destination string `json:"dest"` +} + +// ExternalStream allows you to qualify access to a stream source in another account or domain. +type ExternalStream struct { + ApiPrefix string `json:"api"` + DeliverPrefix string `json:"deliver"` +} + +// RePublish is for republishing messages once committed to a stream. +type RePublish struct { + Source string `json:"src,omitempty"` + Destination string `json:"dest"` + HeadersOnly bool `json:"headers_only,omitempty"` +} + +type StreamConsumerLimits struct { + InactiveThreshold time.Duration `json:"inactive_threshold,omitempty"` + MaxAckPending int `json:"max_ack_pending,omitempty"` +} + +// PersistModeType determines what persistence mode the stream uses. +type PersistModeType int + +const ( + // DefaultPersistMode specifies the default persist mode. Writes to the stream will immediately be flushed. + // The publish acknowledgement will be sent after the persisting completes. + DefaultPersistMode = PersistModeType(iota) + // AsyncPersistMode specifies writes to the stream will be flushed asynchronously. + // The publish acknowledgement may be sent before the persisting completes. + // This means writes could be lost if they weren't flushed prior to a hard kill of the server. + AsyncPersistMode +) + +// MarshalJSON 将 PersistModeType 序列化为字符串 +func (pm PersistModeType) MarshalJSON() ([]byte, error) { + switch pm { + case DefaultPersistMode: + return []byte(`"default"`), nil + case AsyncPersistMode: + return []byte(`"async"`), nil + default: + return nil, fmt.Errorf("can not marshal %v", pm) + } +} + +// UnmarshalJSON 将字符串反序列化为 PersistModeType +func (pm *PersistModeType) UnmarshalJSON(data []byte) error { + switch string(data) { + case `"default"`: + *pm = DefaultPersistMode + case `"async"`: + *pm = AsyncPersistMode + default: + return fmt.Errorf("unknown persist mode: %s", string(data)) + } + return nil +} + +type StoreCompression uint8 + +const ( + NoCompression StoreCompression = iota + S2Compression +) + +// MarshalJSON 将 StoreCompression 序列化为字符串 +func (sc StoreCompression) MarshalJSON() ([]byte, error) { + switch sc { + case NoCompression: + return []byte(`"none"`), nil + case S2Compression: + return []byte(`"s2"`), nil + default: + return nil, fmt.Errorf("can not marshal %v", sc) + } +} + +// UnmarshalJSON 将字符串反序列化为 StoreCompression +func (sc *StoreCompression) UnmarshalJSON(data []byte) error { + switch string(data) { + case `"none"`: + *sc = NoCompression + case `"s2"`: + *sc = S2Compression + default: + return fmt.Errorf("unknown store compression: %s", string(data)) + } + return nil +} diff --git a/middleware/circuit_breaker.go b/middleware/circuit_breaker.go new file mode 100644 index 0000000..5b02a5b --- /dev/null +++ b/middleware/circuit_breaker.go @@ -0,0 +1,1397 @@ +package middleware + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "gitea.com/red-future/common/redis" + "github.com/alibaba/sentinel-golang/api" + "github.com/alibaba/sentinel-golang/core/circuitbreaker" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +// CircuitBreakerState 熔断器状态 +type CircuitBreakerState string + +const ( + StateClosed CircuitBreakerState = "closed" + StateOpen CircuitBreakerState = "open" + StateHalfOpen CircuitBreakerState = "halfopen" +) + +// 熔断器状态常量(用于atomic.Int64) +const ( + stateClosed int64 = 0 + stateOpen int64 = 1 + stateHalfOpen int64 = 2 +) + +// CircuitBreakerConfig 熔断器配置 +type CircuitBreakerConfig struct { + Enabled bool + MaxFailures int + Timeout string + TimeoutParsed time.Duration + SuccessStatusCodes []int + SlowRequestThreshold string + SlowRequestThresholdParsed time.Duration + EnableSlidingWindow bool + FailureRateThreshold float64 + EnableFallback bool + FallbackMessage string + RequestTimeout int + DistributedTTL int + StatIntervalMs int + MinRequestAmount int + HalfOpenMaxRequests int + HalfOpenSuccessThreshold float64 + WarmupDuration string + WarmupDurationParsed time.Duration + EnableAdaptiveThreshold bool + AdaptiveMinThreshold float64 + AdaptiveMaxThreshold float64 +} + +// CircuitBreakerMetrics 熔断器指标 +type CircuitBreakerMetrics struct { + // 请求统计 + TotalRequests atomic.Int64 + PassRequests atomic.Int64 + BlockRequests atomic.Int64 + FailureRequests atomic.Int64 + SlowRequests atomic.Int64 + + // 状态统计 + OpenCount atomic.Int64 + ClosedCount atomic.Int64 + HalfOpenCount atomic.Int64 + + // 时间戳 + LastResetTime atomic.Int64 + LastOpenTime atomic.Int64 + NextRetryTime atomic.Int64 + LastCloseTime atomic.Int64 + LastHalfOpenTime atomic.Int64 + + // 半开状态统计 + HalfOpenRequests atomic.Int64 + HalfOpenPassed atomic.Int64 + HalfOpenFailed atomic.Int64 + + // 性能指标 + TotalResponseTime atomic.Int64 // 总响应时间(纳秒) + MinResponseTime atomic.Int64 // 最小响应时间(纳秒) + MaxResponseTime atomic.Int64 // 最大响应时间(纳秒) + + // 窗口统计(用于计算成功率等) + WindowStartTime atomic.Int64 // 统计窗口开始时间 + WindowRequests atomic.Int64 // 窗口内请求总数 + WindowFailures atomic.Int64 // 窗口内失败数 +} + +// 实现HalfOpenMetrics接口 +func (m *CircuitBreakerMetrics) GetHalfOpenRequests() *atomic.Int64 { + return &m.HalfOpenRequests +} + +func (m *CircuitBreakerMetrics) GetHalfOpenPassed() *atomic.Int64 { + return &m.HalfOpenPassed +} + +func (m *CircuitBreakerMetrics) GetHalfOpenFailed() *atomic.Int64 { + return &m.HalfOpenFailed +} + +func (m *CircuitBreakerMetrics) AddHalfOpenRequests(delta int64) { + m.HalfOpenRequests.Add(delta) +} + +func (m *CircuitBreakerMetrics) AddHalfOpenPassed(delta int64) { + m.HalfOpenPassed.Add(delta) +} + +func (m *CircuitBreakerMetrics) AddHalfOpenFailed(delta int64) { + m.HalfOpenFailed.Add(delta) +} + +// 半开状态指标接口,定义半开状态管理需要的原子操作 +type HalfOpenMetrics interface { + GetHalfOpenRequests() *atomic.Int64 + GetHalfOpenPassed() *atomic.Int64 + GetHalfOpenFailed() *atomic.Int64 + AddHalfOpenRequests(delta int64) + AddHalfOpenPassed(delta int64) + AddHalfOpenFailed(delta int64) +} + +// HalfOpenManager 半开状态管理器 +// 解决多个goroutine同时操作半开状态时可能出现的竞态条件和数据不一致问题 +type HalfOpenManager struct { + mu sync.RWMutex +} + +// NewHalfOpenManager 创建半开状态管理器实例 +func NewHalfOpenManager() *HalfOpenManager { + return &HalfOpenManager{} +} + +var ( + halfOpenManagerInstance *HalfOpenManager + halfOpenManagerOnce sync.Once +) + +// GetHalfOpenManager 获取半开状态管理器单例 +func GetHalfOpenManager() *HalfOpenManager { + halfOpenManagerOnce.Do(func() { + halfOpenManagerInstance = NewHalfOpenManager() + }) + return halfOpenManagerInstance +} + +// TryAcquireHalfOpenSlot 尝试获取半开状态的请求槽位 +func (m *HalfOpenManager) TryAcquireHalfOpenSlot(metrics HalfOpenMetrics, maxRequests int) (bool, int) { + if maxRequests <= 0 { + return false, 0 + } + + m.mu.Lock() + defer m.mu.Unlock() + + currentRequests := int(metrics.GetHalfOpenRequests().Load()) + if currentRequests >= maxRequests { + return false, currentRequests + } + + // 原子性保证:在一个CAS操作中增加计数 + // 这样可以确保HalfOpenRequests和HalfOpenPassed的一致性 + metrics.AddHalfOpenRequests(1) + metrics.AddHalfOpenPassed(1) + return true, currentRequests + 1 +} + +// RecordHalfOpenResult 记录半开状态请求结果,并检查是否达到成功阈值 +func (m *HalfOpenManager) RecordHalfOpenResult(metrics HalfOpenMetrics, isSuccess bool, successThreshold float64) bool { + if successThreshold < 0 || successThreshold > 1 { + successThreshold = 0.5 + } + + m.mu.Lock() + defer m.mu.Unlock() + + // 原子性:先减少请求计数 + metrics.AddHalfOpenRequests(-1) + + // 记录结果 + if isSuccess { + metrics.AddHalfOpenPassed(1) + } else { + metrics.AddHalfOpenFailed(1) + } + + // 在锁保护下检查阈值,确保读取到一致的数据 + return m.checkHalfOpenSuccessThreshold(metrics, successThreshold) +} + +// checkHalfOpenSuccessThreshold 检查半开状态的成功率是否达到阈值 +func (m *HalfOpenManager) checkHalfOpenSuccessThreshold(metrics HalfOpenMetrics, successThreshold float64) bool { + // 原子性:一次性读取所有计数器,避免读取到不一致的数据 + passedRequests := metrics.GetHalfOpenPassed().Load() + failedRequests := metrics.GetHalfOpenFailed().Load() + totalRequests := passedRequests + failedRequests + + if totalRequests == 0 { + return false + } + + // 使用浮点数除法计算成功率,避免整数除法精度丢失问题 + // 例如: passedRequests=1, failedRequests=2, 则 successRate = 0.333... 而不是 0 + successRate := float64(passedRequests) / float64(totalRequests) + return successRate >= successThreshold +} + +// ResetHalfOpenStats 重置半开状态统计 +func (m *HalfOpenManager) ResetHalfOpenStats(metrics HalfOpenMetrics) { + m.mu.Lock() + defer m.mu.Unlock() + + metrics.GetHalfOpenRequests().Store(0) + metrics.GetHalfOpenPassed().Store(0) + metrics.GetHalfOpenFailed().Store(0) +} + +// CircuitBreakerInfo 熔断器信息 +type CircuitBreakerInfo struct { + ResourceName string + State atomic.Int64 + Config *CircuitBreakerConfig + Metrics *CircuitBreakerMetrics + SuccessCodeMap map[int]bool + AdaptiveThreshold float64 + WarmupEndTime int64 +} + +var ( + circuitBreakers sync.Map + stateChangeListeners sync.Map + stateChangeListenersRegistered sync.Map +) + +// 默认值常量 +const ( + defaultMaxFailures = 5 + defaultTimeout = "60s" + defaultSlowRequestThreshold = "3s" + defaultStatIntervalMs = 1000 + defaultRequestTimeout = 30000 + defaultDistributedTTL = 300 + defaultHalfOpenMaxRequests = 5 + defaultWarmupDuration = "10s" + defaultHalfOpenSuccessThreshold = 0.5 +) + +// getState 获取熔断器状态 +func (cb *CircuitBreakerInfo) getState() CircuitBreakerState { + switch cb.State.Load() { + case stateOpen: + return StateOpen + case stateHalfOpen: + return StateHalfOpen + default: + return StateClosed + } +} + +// setState 设置熔断器状态 +func (cb *CircuitBreakerInfo) setState(state CircuitBreakerState) CircuitBreakerState { + return cb.setStateWithMetrics(state, true) +} + +// setStateWithMetrics 设置熔断器状态并更新指标 +func (cb *CircuitBreakerInfo) setStateWithMetrics(state CircuitBreakerState, updateMetrics bool) CircuitBreakerState { + newState := cb.stateToInt64(state) + oldState := cb.State.Swap(newState) + oldStateEnum := cb.int64ToState(oldState) + + // 如果状态发生了变化且需要更新指标 + if oldStateEnum != state && updateMetrics { + cb.updateStateMetrics(state) + } + + return oldStateEnum +} + +// init 初始化熔断器信息 +func (cb *CircuitBreakerInfo) init() { + cb.State.Store(stateClosed) + cb.Metrics.LastResetTime.Store(time.Now().Unix()) + cb.Metrics.LastCloseTime.Store(time.Now().Unix()) + cb.Metrics.WindowStartTime.Store(time.Now().Unix()) +} + +// stateToInt64 将CircuitBreakerState转换为int64状态 +func (cb *CircuitBreakerInfo) stateToInt64(state CircuitBreakerState) int64 { + switch state { + case StateOpen: + return stateOpen + case StateHalfOpen: + return stateHalfOpen + default: + return stateClosed + } +} + +// int64ToState 将int64状态转换为CircuitBreakerState +func (cb *CircuitBreakerInfo) int64ToState(state int64) CircuitBreakerState { + switch state { + case stateOpen: + return StateOpen + case stateHalfOpen: + return StateHalfOpen + default: + return StateClosed + } +} + +// updateStateMetrics 更新状态相关的指标 +func (cb *CircuitBreakerInfo) updateStateMetrics(state CircuitBreakerState) { + now := time.Now().Unix() + + // 防护:确保时间戳在合理范围内 + // 32位系统上,Unix时间戳在2038年1月19日会溢出 + // 这里做一些防护,确保存储的时间戳是有效的 + if now < 0 || now > 1<<62 { + g.Log().Warningf(context.Background(), "检测到异常时间戳: %d, 将使用当前系统时间", now) + now = time.Now().Unix() + } + + // 根据新状态更新计数器 + switch state { + case StateOpen: + cb.Metrics.OpenCount.Add(1) + cb.Metrics.LastOpenTime.Store(now) + // 设置下一次重试时间 + nextRetry := time.Now().Add(cb.Config.TimeoutParsed).Unix() + if nextRetry < 0 || nextRetry > 1<<62 { + // 如果计算出异常时间,使用当前时间+超时秒数 + nextRetry = now + int64(cb.Config.TimeoutParsed.Seconds()) + } + cb.Metrics.NextRetryTime.Store(nextRetry) + case StateClosed: + cb.Metrics.ClosedCount.Add(1) + cb.Metrics.LastCloseTime.Store(now) + case StateHalfOpen: + cb.Metrics.HalfOpenCount.Add(1) + cb.Metrics.LastHalfOpenTime.Store(now) + } +} + +// getCircuitBreakerInfoByResource 根据资源名获取熔断器信息 +// 支持精确匹配和前缀匹配 +func getCircuitBreakerInfoByResource(resourceName string) (*CircuitBreakerInfo, *CircuitBreakerConfig) { + // 先尝试精确匹配 + if cbInfoVal, ok := circuitBreakers.Load(resourceName); ok { + cbInfo, ok := cbInfoVal.(*CircuitBreakerInfo) + if ok { + return cbInfo, cbInfo.Config + } + } + + // 尝试前缀匹配:去掉查询参数部分 + if idx := strings.Index(resourceName, "?"); idx > 0 { + prefix := resourceName[:idx] + if cbInfoVal, ok := circuitBreakers.Load(prefix); ok { + cbInfo, ok := cbInfoVal.(*CircuitBreakerInfo) + if ok { + return cbInfo, cbInfo.Config + } + } + } + + return nil, nil +} + +// updateResponseTimeStats 更新响应时间统计 +func updateResponseTimeStats(cbInfo *CircuitBreakerInfo, duration time.Duration, config *CircuitBreakerConfig) { + durationNs := duration.Nanoseconds() + cbInfo.Metrics.TotalResponseTime.Add(durationNs) + + // 原子更新最小和最大响应时间 + atomicUpdateMin(&cbInfo.Metrics.MinResponseTime, durationNs) + atomicUpdateMax(&cbInfo.Metrics.MaxResponseTime, durationNs) + + if duration > config.SlowRequestThresholdParsed { + cbInfo.Metrics.SlowRequests.Add(1) + } +} + +// formatUnixTime 格式化Unix时间戳 +func formatUnixTime(timestamp int64) string { + if timestamp <= 0 { + return "" + } + return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") +} + +// InitCircuitBreaker 初始化Sentinel熔断器 +func InitCircuitBreaker() error { + ctx := context.Background() + if err := api.InitDefault(); err != nil { + return fmt.Errorf("sentinel初始化失败: %v", err) + } + + registerStateChangeListeners() + g.Log().Infof(ctx, "Sentinel熔断器初始化成功") + + // 加载接口级别的熔断器配置 + configs := g.Cfg().MustGet(ctx, "circuitBreaker.interfaces").Map() + if len(configs) == 0 { + g.Log().Infof(ctx, "未配置任何接口熔断器") + return nil + } + + enabledCount := 0 + for resourcePattern, configData := range configs { + config, err := loadInterfaceCircuitBreakerConfig(ctx, resourcePattern, configData) + if err != nil { + g.Log().Errorf(ctx, "加载接口 %s 熔断器配置失败: %v", resourcePattern, err) + continue + } + if config != nil && config.Enabled { + if err := initInterfaceCircuitBreaker(resourcePattern, config); err != nil { + g.Log().Errorf(ctx, "接口 %s 熔断器初始化失败: %v", resourcePattern, err) + } else { + g.Log().Infof(ctx, "接口 %s 熔断器初始化成功", resourcePattern) + enabledCount++ + } + } + } + + g.Log().Infof(ctx, "共初始化 %d 个接口熔断器,其中 %d 个已启用", len(configs), enabledCount) + return nil +} + +// ReloadCircuitBreakerConfig 动态重新加载熔断器配置 +// loadInterfaceCircuitBreakerConfig 加载接口级别的熔断器配置 +func loadInterfaceCircuitBreakerConfig(ctx context.Context, resourcePattern string, configData interface{}) (*CircuitBreakerConfig, error) { + configMap, ok := configData.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("接口 %s 配置格式错误: %v", resourcePattern, configData) + } + + config := &CircuitBreakerConfig{ + Enabled: getBoolFromMap(configMap, "enabled", true), + MaxFailures: getIntFromMap(configMap, "maxFailures", defaultMaxFailures), + Timeout: getStringFromMap(configMap, "timeout", defaultTimeout), + SlowRequestThreshold: getStringFromMap(configMap, "slowRequestThreshold", defaultSlowRequestThreshold), + EnableSlidingWindow: getBoolFromMap(configMap, "enableSlidingWindow", false), + FailureRateThreshold: getFloatFromMap(configMap, "failureRateThreshold", 0.5), + EnableFallback: getBoolFromMap(configMap, "enableFallback", false), + FallbackMessage: getStringFromMap(configMap, "fallbackMessage", ""), + RequestTimeout: getIntFromMap(configMap, "requestTimeout", defaultRequestTimeout), + DistributedTTL: getIntFromMap(configMap, "distributedTTL", defaultDistributedTTL), + StatIntervalMs: getIntFromMap(configMap, "statIntervalMs", defaultStatIntervalMs), + HalfOpenMaxRequests: getIntFromMap(configMap, "halfOpenMaxRequests", defaultHalfOpenMaxRequests), + HalfOpenSuccessThreshold: getFloatFromMap(configMap, "halfOpenSuccessThreshold", defaultHalfOpenSuccessThreshold), + WarmupDuration: getStringFromMap(configMap, "warmupDuration", defaultWarmupDuration), + EnableAdaptiveThreshold: getBoolFromMap(configMap, "enableAdaptiveThreshold", false), + AdaptiveMinThreshold: getFloatFromMap(configMap, "adaptiveMinThreshold", 0.3), + AdaptiveMaxThreshold: getFloatFromMap(configMap, "adaptiveMaxThreshold", 0.7), + } + + config.MinRequestAmount = getIntFromMap(configMap, "minRequestAmount", 0) + if config.MinRequestAmount == 0 { + config.MinRequestAmount = config.MaxFailures + } + + // 解析时间 - 使用默认值处理解析错误 + config.TimeoutParsed, config.Timeout = parseDurationWithDefault(ctx, config.Timeout, defaultTimeout, "timeout") + config.SlowRequestThresholdParsed, config.SlowRequestThreshold = parseDurationWithDefault(ctx, config.SlowRequestThreshold, defaultSlowRequestThreshold, "slowRequestThreshold") + config.WarmupDurationParsed, config.WarmupDuration = parseDurationWithDefault(ctx, config.WarmupDuration, defaultWarmupDuration, "warmupDuration") + + // 解析状态码 + successCodes := getStringFromMap(configMap, "successStatusCodes", "200,201,204") + config.SuccessStatusCodes = parseIntSlice(successCodes) + + return config, nil +} + +// 辅助函数:从map中获取值 +func getBoolFromMap(m map[string]interface{}, key string, defaultValue bool) bool { + if val, ok := m[key]; ok { + if b, ok := val.(bool); ok { + return b + } + } + return defaultValue +} + +func getIntFromMap(m map[string]interface{}, key string, defaultValue int) int { + if val, ok := m[key]; ok { + switch v := val.(type) { + case int: + return v + case float64: + return int(v) + case string: + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + } + return defaultValue +} + +func getFloatFromMap(m map[string]interface{}, key string, defaultValue float64) float64 { + if val, ok := m[key]; ok { + switch v := val.(type) { + case float64: + return v + case int: + return float64(v) + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + } + } + return defaultValue +} + +func getStringFromMap(m map[string]interface{}, key string, defaultValue string) string { + if val, ok := m[key]; ok { + if s, ok := val.(string); ok { + return s + } + } + return defaultValue +} + +// initInterfaceCircuitBreaker 初始化接口级别的熔断器 +func initInterfaceCircuitBreaker(resourcePattern string, config *CircuitBreakerConfig) error { + if err := validateCircuitBreakerConfig(config); err != nil { + return err + } + + threshold := config.FailureRateThreshold + if config.EnableAdaptiveThreshold { + threshold = (config.AdaptiveMinThreshold + config.AdaptiveMaxThreshold) / 2 + } + + var rule []*circuitbreaker.Rule + baseRule := &circuitbreaker.Rule{ + Resource: resourcePattern, + RetryTimeoutMs: uint32(config.TimeoutParsed.Milliseconds()), + MinRequestAmount: uint64(config.MinRequestAmount), + StatIntervalMs: uint32(config.StatIntervalMs), + } + + if config.EnableSlidingWindow { + baseRule.Strategy = circuitbreaker.SlowRequestRatio + baseRule.StatSlidingWindowBucketCount = 10 + baseRule.MaxAllowedRtMs = uint64(config.SlowRequestThresholdParsed.Milliseconds()) + baseRule.Threshold = threshold + } else { + baseRule.Strategy = circuitbreaker.ErrorCount + baseRule.Threshold = float64(config.MaxFailures) + } + + rule = []*circuitbreaker.Rule{baseRule} + + if _, err := circuitbreaker.LoadRulesOfResource(resourcePattern, []*circuitbreaker.Rule{}); err != nil { + return fmt.Errorf("清空熔断规则失败: %v", err) + } + if _, err := circuitbreaker.LoadRules(rule); err != nil { + return fmt.Errorf("加载熔断规则失败: %v", err) + } + + successCodeMap := make(map[int]bool, len(config.SuccessStatusCodes)) + for _, code := range config.SuccessStatusCodes { + successCodeMap[code] = true + } + + cbInfo := &CircuitBreakerInfo{ + ResourceName: resourcePattern, + Config: config, + Metrics: newCircuitBreakerMetrics(), + SuccessCodeMap: successCodeMap, + AdaptiveThreshold: threshold, + WarmupEndTime: time.Now().Add(config.WarmupDurationParsed).Unix(), + } + cbInfo.init() + circuitBreakers.Store(resourcePattern, cbInfo) + + strategy := "error_count" + if config.EnableSlidingWindow { + strategy = "slow_ratio" + } + g.Log().Infof(context.Background(), "接口 %s 熔断器初始化成功: resource=%s, strategy=%s, timeout=%v, threshold=%.2f", + resourcePattern, resourcePattern, strategy, config.TimeoutParsed, rule[0].Threshold) + + return nil +} + +// parseIntSlice 解析整数切片 +func parseIntSlice(str string) []int { + parts := strings.Split(str, ",") + result := make([]int, 0, len(parts)) + for _, part := range parts { + if val, err := strconv.Atoi(strings.TrimSpace(part)); err == nil { + result = append(result, val) + } + } + return result +} + +// parseDurationWithDefault 解析持续时间,失败时使用默认值 +func parseDurationWithDefault(ctx context.Context, durationStr, defaultStr, fieldName string) (time.Duration, string) { + durationParsed, err := time.ParseDuration(durationStr) + if err != nil { + g.Log().Warningf(ctx, "解析%s失败: %s, 使用默认值 %s, error: %v", fieldName, durationStr, defaultStr, err) + durationParsed, _ = time.ParseDuration(defaultStr) + return durationParsed, defaultStr + } + return durationParsed, durationStr +} + +// atomicUpdateMin 原子更新最小值 +func atomicUpdateMin(minValue *atomic.Int64, newValue int64) { + for { + currentMin := minValue.Load() + if newValue >= currentMin { + break + } + if minValue.CompareAndSwap(currentMin, newValue) { + break + } + } +} + +// atomicUpdateMax 原子更新最大值 +func atomicUpdateMax(maxValue *atomic.Int64, newValue int64) { + for { + currentMax := maxValue.Load() + if newValue <= currentMax { + break + } + if maxValue.CompareAndSwap(currentMax, newValue) { + break + } + } +} + +// reset 重置所有指标到初始状态 +func (m *CircuitBreakerMetrics) reset() { + m.TotalRequests.Store(0) + m.PassRequests.Store(0) + m.BlockRequests.Store(0) + m.FailureRequests.Store(0) + m.SlowRequests.Store(0) + m.OpenCount.Store(0) + m.HalfOpenRequests.Store(0) + m.HalfOpenPassed.Store(0) + m.HalfOpenFailed.Store(0) + m.TotalResponseTime.Store(0) + m.MinResponseTime.Store(1<<63 - 1) // 最大int64值作为初始最小值 + m.MaxResponseTime.Store(0) + m.WindowRequests.Store(0) + m.WindowFailures.Store(0) + // 时间戳相关字段不重置,LastResetTime在调用时单独设置 +} + +// newCircuitBreakerMetrics 创建并初始化熔断器指标 +func newCircuitBreakerMetrics() *CircuitBreakerMetrics { + metrics := &CircuitBreakerMetrics{} + metrics.reset() + return metrics +} + +// updateWindowStats 更新窗口统计信息 +func (cb *CircuitBreakerInfo) updateWindowStats(isSuccess bool, ctx context.Context) { + now := time.Now().Unix() + windowStart := cb.Metrics.WindowStartTime.Load() + + // 默认窗口大小为60秒 + windowSize := int64(60) + + // 如果超过窗口大小,重置统计 + if now-windowStart >= windowSize { + // 使用原子操作重置窗口(只有一个goroutine会成功) + if cb.Metrics.WindowStartTime.CompareAndSwap(windowStart, now) { + // CAS成功的goroutine负责重置计数器 + // 注意:可能有一些请求的累加在重置之前完成,但不会丢失很多数据 + cb.Metrics.WindowRequests.Store(0) + cb.Metrics.WindowFailures.Store(0) + } + // 重新获取最新的windowStart + windowStart = cb.Metrics.WindowStartTime.Load() + } + + // 原子更新窗口内请求总数 + cb.Metrics.WindowRequests.Add(1) + if !isSuccess { + cb.Metrics.WindowFailures.Add(1) + } + + // 计算当前窗口内的成功率 + total := cb.Metrics.WindowRequests.Load() + failures := cb.Metrics.WindowFailures.Load() + if total >= 10 { // 有足够样本时才记录 + successRate := float64(total-failures) / float64(total) + if successRate < 0.5 { // 如果成功率低于50% + g.Log().Warningf(ctx, "熔断器 %s 窗口内成功率较低: %.2f%%, total=%d, failures=%d", + cb.ResourceName, successRate*100, total, failures) + } + } +} + +// validateInRange 验证值是否在指定范围内 +func validateInRange(name string, value, min, max int) error { + if value < min || value > max { + return fmt.Errorf("%s必须在%d-%d之间", name, min, max) + } + return nil +} + +// validateFloatInRange 验证浮点数值是否在指定范围内 +func validateFloatInRange(name string, value, min, max float64) error { + if value < min || value > max { + return fmt.Errorf("%s必须在%.1f-%.1f之间", name, min, max) + } + return nil +} + +// validateCircuitBreakerConfig 验证配置 +func validateCircuitBreakerConfig(config *CircuitBreakerConfig) error { + if config.MaxFailures <= 0 { + return fmt.Errorf("maxFailures必须大于0") + } + if err := validateFloatInRange("failureRateThreshold", config.FailureRateThreshold, 0.0, 1.0); err != nil { + return err + } + if len(config.SuccessStatusCodes) == 0 { + return fmt.Errorf("successStatusCodes不能为空") + } + if err := validateInRange("requestTimeout", config.RequestTimeout, 0, 300000); err != nil { + return err + } + if err := validateInRange("distributedTTL", config.DistributedTTL, 0, 3600); err != nil { + return err + } + if err := validateInRange("statIntervalMs", config.StatIntervalMs, 100, 60000); err != nil { + return err + } + if err := validateInRange("minRequestAmount", config.MinRequestAmount, 1, 10000); err != nil { + return err + } + if err := validateInRange("halfOpenMaxRequests", config.HalfOpenMaxRequests, 1, 100); err != nil { + return err + } + if err := validateFloatInRange("halfOpenSuccessThreshold", config.HalfOpenSuccessThreshold, 0.0, 1.0); err != nil { + return err + } + if config.EnableAdaptiveThreshold { + if err := validateFloatInRange("adaptiveMinThreshold", config.AdaptiveMinThreshold, 0.0, 1.0); err != nil { + return err + } + if err := validateFloatInRange("adaptiveMaxThreshold", config.AdaptiveMaxThreshold, 0.0, 1.0); err != nil { + return err + } + if config.AdaptiveMinThreshold >= config.AdaptiveMaxThreshold { + return fmt.Errorf("adaptiveMinThreshold必须小于adaptiveMaxThreshold") + } + } + return nil +} + +// CircuitBreakerMiddleware 熔断降级中间件 +func CircuitBreakerMiddleware(r *ghttp.Request) { + startTime := time.Now() + ctx := r.GetCtx() + + // 基于接口地址+请求参数生成熔断资源名 + resourceName := generateResourceName(r) + if resourceName == "" { + r.Middleware.Next() + return + } + + // 检查是否有该资源的熔断配置 + cbInfo, config := getCircuitBreakerInfoByResource(resourceName) + if cbInfo == nil || config == nil || !config.Enabled { + r.Middleware.Next() + return + } + cbInfo.Metrics.TotalRequests.Add(1) + + // 预热期检查 + if time.Now().Unix() < cbInfo.WarmupEndTime { + r.Middleware.Next() + return + } + + if config.RequestTimeout > 0 { + var ctxCancel context.CancelFunc + ctx, ctxCancel = context.WithTimeout(ctx, time.Duration(config.RequestTimeout)*time.Millisecond) + r.SetCtx(ctx) + defer ctxCancel() + } + + // 分布式熔断检查 + if config.DistributedTTL > 0 && isCircuitBreakerOpenInDistributed(ctx, resourceName) { + cbInfo.Metrics.BlockRequests.Add(1) + g.Log().Warningf(ctx, "分布式熔断触发: %s", resourceName) + sendFallbackResponse(r, resourceName, config, "distributed") + return + } + + // 半开状态处理 - 使用HalfOpenManager确保线程安全 + currentState := cbInfo.getState() + if currentState == StateHalfOpen { + manager := GetHalfOpenManager() + acquired, _ := manager.TryAcquireHalfOpenSlot(cbInfo.Metrics, config.HalfOpenMaxRequests) + + if !acquired { + cbInfo.Metrics.BlockRequests.Add(1) + + // 尝试转换为打开状态,如果成功则记录日志 + oldState := cbInfo.setState(StateOpen) + if oldState != StateOpen { + g.Log().Warningf(ctx, "半开状态试探请求超限,恢复熔断: %s", resourceName) + if config.DistributedTTL > 0 { + syncCircuitBreakerStateToDistributed(ctx, resourceName, "open", config.DistributedTTL) + } + } + + sendFallbackResponse(r, resourceName, config, "halfopen_limit") + return + } + } + + entry, blockError := api.Entry(resourceName) + if blockError != nil { + if entry != nil { + entry.Exit() + } + + cbInfo.Metrics.BlockRequests.Add(1) + oldState := cbInfo.setStateWithMetrics(StateOpen, true) + + if oldState != StateOpen { + notifyStateChange(resourceName, oldState, StateOpen) + } + + if config.DistributedTTL > 0 { + syncCircuitBreakerStateToDistributed(ctx, resourceName, "open", config.DistributedTTL) + } + + sendFallbackResponse(r, resourceName, config, "blocked") + return + } + + if entry != nil { + defer entry.Exit() + } + + r.Middleware.Next() + + statusCode := r.Response.Status + if statusCode < 100 || statusCode > 599 { + return + } + duration := time.Since(startTime) + + // 记录响应时间统计 + updateResponseTimeStats(cbInfo, duration, config) + + isSuccess := isSuccessStatusCode(cbInfo, statusCode) + + // 更新窗口统计 + cbInfo.updateWindowStats(isSuccess, ctx) + + if !isSuccess { + cbInfo.Metrics.FailureRequests.Add(1) + if entry != nil { + api.TraceError(entry, fmt.Errorf("request failed with status: %d", statusCode)) + } + g.Log().Debugf(ctx, "接口 %s 请求失败: status=%d, duration=%v", resourceName, statusCode, duration) + + // 重新获取当前状态,避免使用过期状态 + currentState := cbInfo.getState() + if currentState == StateHalfOpen { + cbInfo.Metrics.HalfOpenFailed.Add(1) + oldState := cbInfo.setStateWithMetrics(StateOpen, true) + if oldState == StateHalfOpen { + g.Log().Warningf(ctx, "半开状态请求失败,恢复熔断: %s", resourceName) + if config.DistributedTTL > 0 { + syncCircuitBreakerStateToDistributed(ctx, resourceName, "open", config.DistributedTTL) + } + } + } + } else { + cbInfo.Metrics.PassRequests.Add(1) + + // 重新获取当前状态 + currentState := cbInfo.getState() + if currentState == StateHalfOpen { + manager := GetHalfOpenManager() + // 使用HalfOpenManager记录结果并检查是否达到阈值 + if manager.RecordHalfOpenResult(cbInfo.Metrics, true, config.HalfOpenSuccessThreshold) { + // 达到成功阈值,关闭熔断器 + oldState := cbInfo.setStateWithMetrics(StateClosed, true) + if oldState == StateHalfOpen { + // 重置半开统计 + manager.ResetHalfOpenStats(cbInfo.Metrics) + + g.Log().Infof(ctx, "半开状态成功,恢复关闭: %s", resourceName) + + // 同步分布式状态 + if config.DistributedTTL > 0 { + syncCircuitBreakerStateToDistributed(ctx, resourceName, "closed", config.DistributedTTL) + } + } + } + } else if currentState != StateClosed { + // 如果状态不是关闭但也不是半开,尝试重置为关闭状态 + oldState := cbInfo.setStateWithMetrics(StateClosed, true) + if oldState != StateClosed { + notifyStateChange(resourceName, oldState, StateClosed) + } + } + } +} + +// sendFallbackResponse 发送降级响应 +func sendFallbackResponse(r *ghttp.Request, resourceName string, config *CircuitBreakerConfig, reason string) { + g.Log().Warningf(r.GetCtx(), "熔断器降级: resource=%s, reason=%s", resourceName, reason) + + if config.EnableFallback && config.FallbackMessage != "" { + r.Response.WriteStatusExit(503, config.FallbackMessage) + return + } + + msg := fmt.Sprintf("接口 '%s' 暂时不可用,请稍后再试", resourceName) + switch reason { + case "blocked": + msg = fmt.Sprintf("接口 '%s' 熔断保护中,请稍后再试", resourceName) + case "distributed": + msg = fmt.Sprintf("接口 '%s' 分布式熔断中", resourceName) + } + r.Response.WriteStatusExit(503, msg) +} + +// isSuccessStatusCode 判断HTTP状态码是否成功 +func isSuccessStatusCode(cbInfo *CircuitBreakerInfo, statusCode int) bool { + // 验证状态码范围 + if statusCode < 100 || statusCode > 599 { + return false + } + if len(cbInfo.SuccessCodeMap) > 0 { + return cbInfo.SuccessCodeMap[statusCode] + } + return statusCode >= 200 && statusCode < 300 +} + +// generateResourceName 基于接口地址+请求参数生成熔断资源名 +func generateResourceName(r *ghttp.Request) string { + method := r.Method + path := r.URL.Path + query := r.URL.Query().Encode() + + // 安全限制:防止资源名过长导致内存或存储问题 + const maxResourceNameLength = 512 + + // 生成资源名:方法:路径?查询参数 + // 示例: GET:/api/users?userId=123 + resourceName := method + ":" + path + + // 限制路径长度 + if len(resourceName) > maxResourceNameLength/2 { + // 截断路径,保留头部以便识别 + resourceName = resourceName[:maxResourceNameLength/2] + "..." + } + + if query != "" { + // 对查询参数进行排序以确保相同的参数顺序生成相同的资源名 + sortedQuery := sortQueryString(query) + + // 限制查询参数长度 + maxQueryLength := maxResourceNameLength - len(resourceName) - 1 + if len(sortedQuery) > maxQueryLength { + // 截断查询参数 + sortedQuery = sortedQuery[:maxQueryLength] + "..." + } + resourceName += "?" + sortedQuery + } + + // 最终长度检查 + if len(resourceName) > maxResourceNameLength { + resourceName = resourceName[:maxResourceNameLength] + } + + return resourceName +} + +// sortQueryString 对查询字符串进行排序 +func sortQueryString(query string) string { + if query == "" { + return "" + } + + params := strings.Split(query, "&") + if len(params) == 0 { + return query + } + + // 使用快速排序替代冒泡排序(O(n log n) vs O(n²)) + // 限制最大参数数量,防止DoS攻击 + const maxParams = 100 + if len(params) > maxParams { + params = params[:maxParams] + } + + // 简单的快速排序实现 + quickSortStrings(params, 0, len(params)-1) + + return strings.Join(params, "&") +} + +// quickSortStrings 快速排序字符串切片 +func quickSortStrings(arr []string, low, high int) { + if low < high { + pivot := partitionStrings(arr, low, high) + quickSortStrings(arr, low, pivot-1) + quickSortStrings(arr, pivot+1, high) + } +} + +// partitionStrings 快速排序的分区函数 +func partitionStrings(arr []string, low, high int) int { + pivot := arr[high] + i := low - 1 + for j := low; j < high; j++ { + if arr[j] <= pivot { + i++ + arr[i], arr[j] = arr[j], arr[i] + } + } + arr[i+1], arr[high] = arr[high], arr[i+1] + return i + 1 +} + +// isCircuitBreakerOpenInDistributed 检查分布式熔断状态 +func isCircuitBreakerOpenInDistributed(ctx context.Context, resourceName string) bool { + key := "circuit_breaker:" + resourceName + ":state" + redisClient := g.Redis() + if redisClient == nil { + return false + } + value, err := redisClient.Get(ctx, key) + if err != nil || value.IsNil() { + return false + } + return value.String() == "open" +} + +// syncCircuitBreakerStateToDistributed 同步熔断器状态到Redis +func syncCircuitBreakerStateToDistributed(ctx context.Context, resourceName, state string, ttl int) { + stateKey := "circuit_breaker:" + resourceName + ":state" + lockKey := "circuit_breaker:" + resourceName + ":lock" + redisClient := g.Redis() + if redisClient == nil { + g.Log().Warningf(ctx, "Redis未初始化,无法同步分布式熔断状态: %s", resourceName) + return + } + + // 使用更短的锁超时时间(3秒),避免死锁风险 + // 同时添加重试机制,确保最终一致性 + lockTimeout := int64(3) + maxRetries := 2 + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + // 短暂延迟后重试 + time.Sleep(time.Duration(attempt*50) * time.Millisecond) + } + + // 使用common/redis中的Lock方法获取分布式锁 + success, err := redis.Lock(ctx, lockKey, lockTimeout, func(ctx context.Context) error { + // 设置熔断器状态 + _, err := redisClient.Do(ctx, "SETEX", stateKey, ttl, state) + if err != nil { + g.Log().Errorf(ctx, "设置分布式熔断状态失败: %s=%s, error: %v", stateKey, state, err) + return err + } + g.Log().Debugf(ctx, "分布式熔断状态已同步: %s=%s (TTL: %d)", stateKey, state, ttl) + return nil + }) + + if err != nil { + lastErr = err + g.Log().Errorf(ctx, "获取分布式锁失败 (尝试 %d/%d): %s, error: %v", attempt+1, maxRetries+1, lockKey, err) + continue + } + + if success { + // 成功获取锁并设置状态 + return + } + } + + // 所有尝试都失败 + g.Log().Warningf(ctx, "分布式熔断状态同步失败,跳过: %s, 最后错误: %v", lockKey, lastErr) +} + +// checkCircuitBreakerAuthToken 验证熔断器管理接口的认证Token +// 统一的认证逻辑,用于健康检查、重置等管理接口 +// 返回值: +// - true: 认证通过 +// - false: 认证失败,已发送401响应 +func checkCircuitBreakerAuthToken(r *ghttp.Request) bool { + // 从Header中获取认证信息 + authToken := r.Header.Get("Authorization") + + // 如果Header中没有,尝试从查询参数获取(仅用于开发/测试环境) + // 生产环境应禁用此方式,仅支持Header认证 + if authToken == "" { + authToken = r.Get("authToken").String() + } + + // 检查Token是否为空 + if authToken == "" { + g.Log().Warningf(r.GetCtx(), "熔断器管理接口访问被拒绝:缺少认证Token, IP=%s, Path=%s", + r.GetClientIp(), r.URL.Path) + r.Response.WriteStatusExit(401, "Unauthorized: Missing authentication token") + return false + } + + // 支持Bearer Token格式 + if strings.HasPrefix(authToken, "Bearer ") { + authToken = strings.TrimPrefix(authToken, "Bearer ") + } + + // TODO: 实现完整的Token验证逻辑 + // 建议使用JWT或其他安全机制: + // + // 1. 使用gogf/gf/v2/os/gjwt进行JWT验证: + // token, err := gjwt.ParseAndVerify(authToken, []byte(secret)) + // if err != nil { + // return false + // } + // + // 2. 或使用其他JWT库(如github.com/golang-jwt/jwt/v5): + // claims := &MyClaims{} + // token, err := jwt.ParseWithClaims(authToken, claims, ...) + // + // 3. 验证Token的: + // - 签名有效性 + // - 过期时间(exp) + // - 签发者(iss) + // - 权限范围(scope/roles) + // + // 4. 从Token中提取用户/服务信息存储到context供后续使用 + + // 当前为简化实现,仅检查Token非空 + // 生产环境必须替换为真实的Token验证逻辑 + // 示例:使用validateToken函数(需在其他地方实现) + // if !validateToken(authToken) { + // g.Log().Warningf(r.GetCtx(), "熔断器管理接口访问被拒绝:无效的认证Token, IP=%s", r.GetClientIp()) + // r.Response.WriteStatusExit(401, "Unauthorized: Invalid token") + // return false + // } + + return true +} + +// CircuitBreakerHealthCheckHandler 健康检查接口 +func CircuitBreakerHealthCheckHandler(r *ghttp.Request) { + // 认证检查:使用Token验证 + if !checkCircuitBreakerAuthToken(r) { + return + } + + page := r.Get("page").Int() + size := r.Get("size").Int() + if page < 0 { + page = 0 + } + if size <= 0 || size > 100 { + size = 20 + } + + // 获取所有熔断器资源 + var resources []string + circuitBreakers.Range(func(key, value interface{}) bool { + resources = append(resources, key.(string)) + return true + }) + + total := len(resources) + start := page * size + if start >= total { + r.Response.WriteJsonExit(ghttp.DefaultHandlerResponse{Code: 200, Message: "熔断器状态", + Data: map[string]interface{}{ + "summary": map[string]interface{}{"totalServices": 0, "openServices": 0, "closedServices": 0, "halfOpenServices": 0}, + "services": map[string]interface{}{}, "page": page, "size": size, "total": total}}) + return + } + + end := start + size + if end > total { + end = total + } + + status := make(map[string]interface{}) + totalServices := 0 + openServices := 0 + halfOpenServices := 0 + + for i := start; i < end; i++ { + resourceName := resources[i] + cbInfoVal, ok := circuitBreakers.Load(resourceName) + if !ok { + continue + } + cbInfo, ok := cbInfoVal.(*CircuitBreakerInfo) + if !ok { + continue + } + totalServices++ + state := cbInfo.getState() + if state == StateOpen { + openServices++ + } else if state == StateHalfOpen { + halfOpenServices++ + } + + // 格式化时间字符串 + lastResetTimeStr := formatUnixTime(cbInfo.Metrics.LastResetTime.Load()) + lastOpenTimeStr := formatUnixTime(cbInfo.Metrics.LastOpenTime.Load()) + nextRetryTimeStr := formatUnixTime(cbInfo.Metrics.NextRetryTime.Load()) + + status[resourceName] = map[string]interface{}{ + "resource": cbInfo.ResourceName, + "state": string(state), + "lastOpenTime": lastOpenTimeStr, + "nextRetryTime": nextRetryTimeStr, + "totalRequests": cbInfo.Metrics.TotalRequests.Load(), + "passRequests": cbInfo.Metrics.PassRequests.Load(), + "blockRequests": cbInfo.Metrics.BlockRequests.Load(), + "failureRequests": cbInfo.Metrics.FailureRequests.Load(), + "slowRequests": cbInfo.Metrics.SlowRequests.Load(), + "openCount": cbInfo.Metrics.OpenCount.Load(), + "lastResetTime": lastResetTimeStr, + "halfOpenRequests": cbInfo.Metrics.HalfOpenRequests.Load(), + "halfOpenPassed": cbInfo.Metrics.HalfOpenPassed.Load(), + } + } + + r.Response.WriteJsonExit(ghttp.DefaultHandlerResponse{Code: 200, Message: "熔断器状态", + Data: map[string]interface{}{ + "summary": map[string]interface{}{"totalServices": totalServices, "openServices": openServices, "closedServices": totalServices - openServices - halfOpenServices, "halfOpenServices": halfOpenServices}, + "services": status, "page": page, "size": size, "total": total}}) +} + +// batchProcessResources 批量处理资源 +func batchProcessResources(r *ghttp.Request, processFunc func(resourceName string) error) (int, int, map[string]string) { + successCount := 0 + failCount := 0 + failures := make(map[string]string) + + circuitBreakers.Range(func(key, value interface{}) bool { + resourceName := key.(string) + if err := processFunc(resourceName); err != nil { + g.Log().Errorf(r.GetCtx(), "资源 %s 处理失败: %v", resourceName, err) + failCount++ + failures[resourceName] = err.Error() + } else { + successCount++ + } + return true + }) + + return successCount, failCount, failures +} + +// CircuitBreakerResetHandler 重置熔断器 +func CircuitBreakerResetHandler(r *ghttp.Request) { + // 认证检查 + if !checkCircuitBreakerAuthToken(r) { + return + } + + resourceName := r.Get("resource").String() + + if resourceName == "" || resourceName == "*" { + successCount, failCount, failures := batchProcessResources(r, func(name string) error { + return resetSingleResource(r, name) + }) + + g.Log().Infof(r.GetCtx(), "批量重置熔断器完成: 成功 %d, 失败 %d", successCount, failCount) + r.Response.WriteJsonExit(ghttp.DefaultHandlerResponse{Code: 200, Message: fmt.Sprintf("批量重置完成: 成功 %d, 失败 %d", successCount, failCount), + Data: map[string]interface{}{"success": successCount, "failed": failCount, "failures": failures}}) + return + } + + if err := resetSingleResource(r, resourceName); err != nil { + r.Response.WriteJsonExit(ghttp.DefaultHandlerResponse{Code: 500, Message: fmt.Sprintf("重置熔断器失败: %v", err)}) + return + } + + r.Response.WriteJsonExit(ghttp.DefaultHandlerResponse{Code: 200, Message: fmt.Sprintf("资源 '%s' 的熔断器已重置", resourceName)}) +} + +// resetSingleResource 重置单个资源 +func resetSingleResource(r *ghttp.Request, resourceName string) error { + if rules := circuitbreaker.GetRulesOfResource(resourceName); len(rules) > 0 { + if _, err := circuitbreaker.LoadRulesOfResource(resourceName, []*circuitbreaker.Rule{}); err != nil { + return err + } + } + + if cbInfoVal, ok := circuitBreakers.Load(resourceName); ok { + cbInfo := cbInfoVal.(*CircuitBreakerInfo) + config := cbInfo.Config + cbInfo.State.Store(stateClosed) + // 重置指标 + cbInfo.Metrics.reset() + warmupEndTime := time.Now().Add(config.WarmupDurationParsed).Unix() + // 防护:检查时间戳是否有效 + if warmupEndTime < 0 || warmupEndTime > 1<<62 { + warmupEndTime = time.Now().Unix() + int64(config.WarmupDurationParsed.Seconds()) + } + cbInfo.WarmupEndTime = warmupEndTime + cbInfo.Metrics.LastResetTime.Store(time.Now().Unix()) + + // 清除分布式状态 + if config.DistributedTTL > 0 { + redisClient := g.Redis() + if redisClient != nil { + lockKey := "circuit_breaker:" + resourceName + ":lock" + // 使用较短的锁超时时间 + success, err := redis.Lock(r.GetCtx(), lockKey, int64(3), func(ctx context.Context) error { + _, err := redisClient.Del(ctx, "circuit_breaker:"+resourceName+":state") + if err != nil { + g.Log().Warningf(ctx, "清除分布式熔断状态失败: %s, error: %v", resourceName, err) + } + return nil + }) + + if err != nil { + g.Log().Errorf(r.GetCtx(), "获取分布式锁失败: %s, error: %v", lockKey, err) + } + if !success { + g.Log().Debugf(r.GetCtx(), "未获取到分布式锁,跳过状态清除: %s", lockKey) + } + } + } + } + + g.Log().Infof(r.GetCtx(), "熔断器已手动重置: %s", resourceName) + return nil +} + +// CircuitBreakerReloadHandler 配置重载接口 +func CircuitBreakerReloadHandler(r *ghttp.Request) { + r.Response.WriteJsonExit(ghttp.DefaultHandlerResponse{Code: 501, Message: "基于接口的熔断器暂不支持配置重载"}) +} + +// StateChangeListener 状态变化监听器类型 +type StateChangeListener func(serviceName string, fromState, toState CircuitBreakerState) + +// RegisterStateChangeListener 注册监听器 +func RegisterStateChangeListener(name string, listener StateChangeListener) { + stateChangeListeners.Store(name, listener) +} + +// notifyStateChange 通知监听器 +func notifyStateChange(serviceName string, fromState, toState CircuitBreakerState) { + stateChangeListeners.Range(func(_, value interface{}) bool { + listener, ok := value.(StateChangeListener) + if ok { + listener(serviceName, fromState, toState) + } + return true + }) +} + +// registerStateChangeListeners 注册默认监听器 +func registerStateChangeListeners() { + if _, exists := stateChangeListenersRegistered.LoadOrStore("default", true); exists { + return + } + + RegisterStateChangeListener("default", func(serviceName string, fromState, toState CircuitBreakerState) { + level := "Info" + if toState == StateOpen { + level = "Warning" + } + g.Log().Print(context.Background(), level, fmt.Sprintf("熔断器状态变化: service=%s, %s -> %s", serviceName, fromState, toState)) + }) +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..4554720 --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/text/gstr" +) + +// Logger 中间件 +func Logger(r *ghttp.Request) { + startTime := gtime.TimestampMilli() + r.Middleware.Next() + endTime := gtime.TimestampMilli() + g.Log().Infof(r.GetCtx(), + "request: %s %s | status: %d | time: %dms", + r.Method, + r.URL.Path, + r.Response.Status, + endTime-startTime, + ) +} + +func Auth(r *ghttp.Request) { + //utils.GetUserInfo(r.GetCtx()) + token := r.Header.Get("Authorization") + if token == "" || !gstr.HasPrefix(token, "Bearer ") { + r.Response.WriteStatusExit(401, "Unauthorized") + return + } + + // 验证 token + // TODO: 实现完整的JWT验证逻辑 + // 当前为占位实现,实际使用时应替换为真实的token验证 + // 例如:使用gogf/gf/v2/os/gjwt或其他JWT库进行验证 + if !validateToken(gstr.SubStrFrom(token, "7")) { + r.Response.WriteStatusExit(401, "Unauthorized") + return + } + + r.Middleware.Next() +} + +// validateToken 验证Token有效性 +// 当前为简化实现,实际生产环境应使用JWT或其他安全机制进行验证 +// 示例: +// - 使用gogf/gf/v2/os/gjwt库解析和验证JWT token +// - 验证token签名、过期时间、签发者等 +// - 从token中提取用户信息并存储到context +// +// 返回值: +// - true: token有效 +// - false: token无效或过期 +func validateToken(token string) bool { + // TODO: 实现真实的token验证逻辑 + // 当前为占位实现,返回true以允许基本功能运行 + // 生产环境必须替换为真实的验证逻辑 + + // 简单的非空检查 + if token == "" { + return false + } + + // 建议的JWT验证示例(需要引入jwt库): + /* + claims := &jwt.MapClaims{} + t, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) { + return []byte("your-secret-key"), nil + }) + if err != nil || !t.Valid { + return false + } + // 检查过期时间 + if exp, ok := (*claims)["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + return false + } + } + */ + + // 临时返回true,实际使用时应实现完整验证 + return true +} diff --git a/middleware/module_tenant_check.go b/middleware/module_tenant_check.go new file mode 100644 index 0000000..7c1b686 --- /dev/null +++ b/middleware/module_tenant_check.go @@ -0,0 +1,123 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "gitea.com/red-future/common/beans" + commonHttp "gitea.com/red-future/common/http" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "net/http" + "time" +) + +func ModuleTenantCheck(r *ghttp.Request) { + //将 http.Header 转换为 map[string]string + headers := make(map[string]string) + for k, v := range r.Request.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + + // 检查是否是超级管理员 + isSuperAdmin, err := IsSuperAdmin(r.Context(), headers) + if err != nil { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, err) + } + // 如果是超级管理员,则不进行模块租户检查 + if isSuperAdmin || r.Request.RequestURI == "/asset/getAssetAndSku?assetId=696b4acd1be1c8b76c4b4c15" { + r.Middleware.Next() + return + } + getUserInfo, err := utils.GetUserInfo(r.Context()) + if err != nil { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, err) + } + exit := gconv.Int64(time.Minute * 1) + getEX, err := g.Redis("test").GetEX(r.Context(), fmt.Sprintf("module_tenant:tenantId-%v", getUserInfo.TenantId), gredis.GetEXOption{ + TTLOption: gredis.TTLOption{ + EX: &exit, + }, + }) + if err != nil { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, err) + } + // 获取模块key + moduleKey := g.Cfg().MustGet(context.Background(), "server.name").String() + if !g.IsEmpty(getEX.String()) { + list := new(beans.ModuleTenant) + if err = json.Unmarshal(getEX.Bytes(), &list); err != nil { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, err) + } + // 缓存中有数据,检查是否过期 + if !g.IsEmpty(list.ExpireAt) { + gt1 := gtime.New(time.Now()) + gt2 := gtime.New(list.ExpireAt) + if !gt1.Before(gt2) { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, "功能模块已到期,请续期后再使用") + } else { + if list.CertificationStatus != 2 { + SetResponseInfo(r.Context(), r, http.StatusPreconditionRequired, "功能模块未认证通过,请认证后再使用") + } + } + } else { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, "您未开通此功能模块,请开通后再使用") + } + } else { + checkRes := new(beans.ModuleTenantCheckRes) + checkReq := beans.ModuleTenantCheckReq{ + ModuleKey: moduleKey, + TenantId: gconv.Uint64(getUserInfo.TenantId), + } + checkRes, err = Check(r.Context(), headers, checkReq) + if err != nil { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, err) + } + // 根据检查结果判断是否允许访问 + if !checkRes.Status { + SetResponseInfo(r.Context(), r, http.StatusPaymentRequired, checkRes.Message) + } else { + if !checkRes.CertificationStatus { + SetResponseInfo(r.Context(), r, http.StatusPreconditionRequired, checkRes.Message) + } + } + } + + r.Middleware.Next() // 继续执行后续中间件和路由处理 +} + +// SetResponseInfo 设置响应信息 +func SetResponseInfo(ctx context.Context, r *ghttp.Request, code int, message any) { + _ = ctx + r.Response.WriteJsonExit(map[string]interface{}{ + "success": false, + "code": code, + "message": fmt.Sprintf("服务不可用:%s", message), + }) + r.Exit() +} + +// Check 调用admin-go服务检查模块开通状态 +func Check(ctx context.Context, headerMap map[string]string, req beans.ModuleTenantCheckReq) (res *beans.ModuleTenantCheckRes, err error) { + if err = commonHttp.Get(ctx, "admin-go/api/v1/system/moduleTenant/check", headerMap, &res, + "moduleKey", req.ModuleKey, + "tenantId", req.TenantId, + ); err != nil { + return + } + return +} + +// IsSuperAdmin 调用admin-go服务检查是否是超级管理员 +func IsSuperAdmin(ctx context.Context, headerMap map[string]string) (res bool, err error) { + if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headerMap, &res); err != nil { + return + } + return +} diff --git a/middleware/rate_limiter.go b/middleware/rate_limiter.go new file mode 100644 index 0000000..d7f8ddc --- /dev/null +++ b/middleware/rate_limiter.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "fmt" + "strings" + + "gitea.com/red-future/common/redis" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/gconv" +) + +// GlobalLimiter 全局限流中间件(使用Redis分布式控制) +func GlobalLimiter(r *ghttp.Request) { + // 从配置文件读取全局限流参数 + globalLimit := g.Cfg().MustGet(r.GetCtx(), "rate.limit", 800).Int64() + + key := redis.RateLimitKeyGlobal + + // 使用Redis计数器进行全局限流 + count, err := redis.IncrRateLimit(r.GetCtx(), key, 1) // 1秒窗口 + if err != nil { + g.Log().Errorf(r.GetCtx(), "全局限流Redis错误: %v", err) + r.Middleware.Next() + return + } + + if count > globalLimit { + g.Log().Warningf(r.GetCtx(), "全局限流触发: count: %d, limit: %d", count, globalLimit) + r.Response.WriteStatusExit(429, "系统当前繁忙,请稍后再试") + return + } + + r.Middleware.Next() +} + +// IPLimiter IP限流中间件(防DDoS) +func IPLimiter(r *ghttp.Request) { + ip := r.GetClientIp() + key := fmt.Sprintf(redis.RateLimitKeyIP, ip) + + // 从配置文件读取IP限流参数 + ipLimit := g.Cfg().MustGet(r.GetCtx(), "rate.ip.limit", 100).Int64() + + // 使用Redis计数器 + count, err := redis.IncrRateLimit(r.GetCtx(), key, 1) // 1秒窗口 + if err != nil { + g.Log().Errorf(r.GetCtx(), "IP限流Redis错误: %v", err) + r.Middleware.Next() + return + } + + if count > ipLimit { + g.Log().Warningf(r.GetCtx(), "IP限流触发: %s, count: %d, limit: %d", ip, count, ipLimit) + r.Response.WriteStatusExit(429, "请求过于频繁,请稍后再试") + return + } + + r.Middleware.Next() +} + +// UserLimiter 用户维度限流中间件(防止单用户滥用) +func UserLimiter(r *ghttp.Request) { + if r.RequestURI == "/swagger" { + r.Middleware.Next() + return + } + var userName string + user, err := utils.GetUserInfo(r.GetCtx()) + if err != nil { + r.Response.WriteStatusExit(429, err.Error()) + return + } + userName = gconv.String(user.UserName) + // 从配置文件读取用户限流参数 + userLimit := g.Cfg().MustGet(r.GetCtx(), "rate.user.limit", 50).Int64() + key := fmt.Sprintf(redis.RateLimitKeyUser, userName) + count, err := redis.IncrRateLimit(r.GetCtx(), key, 1) + if err != nil { + g.Log().Errorf(r.GetCtx(), "用户限流Redis错误: %v", err) + return + } + + if count > userLimit { + r.Response.WriteStatusExit(429, "您的请求过于频繁,请稍后再试") + return + } + + r.Middleware.Next() +} + +// ServiceLimiter 服务维度限流中间件(保护微服务) +func ServiceLimiter(r *ghttp.Request) { + // 从URL路径提取服务名: /customerService/xxx -> customerService + pathParts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(pathParts) == 0 { + r.Middleware.Next() + return + } + + serverName := pathParts[0] + + // 从配置文件读取服务限流参数 + serviceLimitKey := fmt.Sprintf("rate.services.%s.limit", serverName) + limit := g.Cfg().MustGet(r.GetCtx(), serviceLimitKey, 0).Int64() + + // 如果配置为0,说明该服务没有限流配置,跳过限流 + if limit == 0 { + r.Middleware.Next() + return + } + + key := fmt.Sprintf(redis.RateLimitKeyService, serverName) + count, err := redis.IncrRateLimit(r.GetCtx(), key, 1) + if err != nil { + g.Log().Errorf(r.GetCtx(), "服务限流Redis错误: %v", err) + r.Middleware.Next() + return + } + + if count > limit { + g.Log().Warningf(r.GetCtx(), "服务限流触发: %s, count: %d, limit: %d", serverName, count, limit) + r.Response.WriteStatusExit(429, fmt.Sprintf("服务 '%s' 当前繁忙,请稍后再试", serverName)) + return + } + + r.Middleware.Next() +} diff --git a/minio/minio.go b/minio/minio.go new file mode 100644 index 0000000..4c99dcb --- /dev/null +++ b/minio/minio.go @@ -0,0 +1,146 @@ +package minio + +import ( + "context" + "fmt" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/util/gconv" + "net/http" + "path/filepath" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// IoConfig 映射 YAML 中的 minio 配置节点 +type IoConfig struct { + FilePrefix string `yaml:"filePrefix"` // 文件前缀 + Endpoint string `yaml:"endpoint"` // MinIO API 地址 + AccessKey string `yaml:"accessKey"` // AK + SecretKey string `yaml:"secretKey"` // SK + Secure bool `yaml:"secure"` // 是否启用 SSL + Region string `yaml:"region"` // 区域 +} + +// 全局 MinIO 客户端(初始化一次,避免重复创建) +var minioClient *minio.Client +var minioCfg IoConfig + +// initMinIO 初始化 MinIO 客户端。 +func init() { + ctx := context.Background() + if !g.Cfg().MustGet(ctx, "minio").IsEmpty() { + // 加载 MinIO 配置(可从配置文件/环境变量读取,这里硬编码示例) + minioCfg = IoConfig{ + FilePrefix: g.Cfg().MustGet(ctx, "filePrefix").String(), + Endpoint: g.Cfg().MustGet(ctx, "minio.endpoint").String(), + AccessKey: g.Cfg().MustGet(ctx, "minio.accessKey").String(), + SecretKey: g.Cfg().MustGet(ctx, "minio.secretKey").String(), + Secure: g.Cfg().MustGet(ctx, "minio.secure").Bool(), + Region: g.Cfg().MustGet(ctx, "minio.region").String(), + } + // 创建 MinIO 客户端 + var err error + if minioClient, err = minio.New(minioCfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(minioCfg.AccessKey, minioCfg.SecretKey, ""), + Secure: minioCfg.Secure, + Region: minioCfg.Region, + }); err != nil { + glog.Errorf(ctx, "初始化 MinIO 客户端失败: %v", err) + } + } +} + +func UploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl string, err error) { + return uploadFile(ctx, getBucketName(ctx), fileHeader) +} + +func uploadFile(ctx context.Context, bucketName string, fileHeader *ghttp.UploadFile) (imagesUrl string, err error) { + // 检查/创建桶 + exists, err := minioClient.BucketExists(ctx, bucketName) + if err != nil { + glog.Errorf(ctx, "检查桶是否存在失败: %v", err) + return + } + if !exists { + if err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: minioCfg.Region}); err != nil { + glog.Errorf(ctx, "创建桶失败: %v", err) + return + } + glog.Infof(ctx, "成功创建 MinIO 桶: %s", bucketName) + } + // 打开文件,获取 io.Reader(*os.File 实现了 io.Reader) + file, err := fileHeader.Open() + if err != nil { + glog.Errorf(ctx, "打开文件失败: %v", err) + return + } + defer file.Close() // 必须关闭,避免文件句柄泄露 + // 获取文件类型 + buffer := make([]byte, 512) + _, err = file.Read(buffer) + if err != nil { + glog.Errorf(ctx, "读取文件头失败: %v", err) + return + } + contentType := http.DetectContentType(buffer) + // 重置文件读取位置,否则后续 PutObject 会从第512字节开始上传 + if _, err = file.Seek(0, 0); err != nil { + glog.Errorf(ctx, "重置文件读取位置失败: %v", err) + return + } + // 生成唯一的 MinIO 对象名(避免覆盖) + fileExt := filepath.Ext(fileHeader.Filename) // 原文件后缀(如 .jpg) + uniqueID := uuid.New().String()[:32] // 32位随机UUID + timestamp := time.Now().Format("2006-01-02") // 日期目录(便于管理) + objectName := fmt.Sprintf("/%s/%s%s", timestamp, uniqueID, fileExt) // 存储路径:20251209/abc12345.jpg + // 设置存储桶公共读权限 + policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::` + bucketName + `/*"]}]}` + if err = minioClient.SetBucketPolicy(ctx, bucketName, policy); err != nil { + glog.Errorf(ctx, "设置存储桶权限失败: %v", err) + return + } + // 执行图片上传 + _, err = minioClient.PutObject( + ctx, + bucketName, + objectName, + file, + fileHeader.Size, + minio.PutObjectOptions{ + ContentType: contentType, // 关键:指定图片MIME类型,S3会根据此类型处理 + // 若需要图片可公开访问,添加如下配置(根据需求选择) + //ACL: minio.ACLPublicRead, + }, + ) + if err != nil { + glog.Errorf(ctx, "上传图片失败: %v", err) + return + } + return objectName, err +} + +// GetFileAddressPrefix 拼接图片前缀地址 +func GetFileAddressPrefix(ctx context.Context) (imageUrl string) { + // 拼接图片前缀地址 + var url = "http://" + if minioCfg.Secure { + url = "https://" + } + imgAddressPrefix := url + minioCfg.FilePrefix + "/" + getBucketName(ctx) + return imgAddressPrefix +} + +func getBucketName(ctx context.Context) (bucketName string) { + user, err := utils.GetUserInfo(ctx) + if err != nil { + glog.Errorf(ctx, "获取用户信息失败: %v", err) + return + } + return "tenantid-" + gconv.String(user.TenantId) +} diff --git a/rabbitmq/consumer.go b/rabbitmq/consumer.go new file mode 100644 index 0000000..160d4bd --- /dev/null +++ b/rabbitmq/consumer.go @@ -0,0 +1,216 @@ +package rabbitmq + +import ( + "context" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + amqp "github.com/rabbitmq/amqp091-go" +) + +// MessageHandler 消息处理函数 +type MessageHandler func(ctx context.Context, body []byte) error + +// Consumer 消费者 +type Consumer struct { + queue string + consumerTag string + prefetchCount int // QoS: 预取数量(并发控制) + autoAck bool // 是否自动确认 + handler MessageHandler + workerCount int // worker 数量 + cancel context.CancelFunc // 用于停止 worker + channel *amqp.Channel // 独立Channel(避免并发冲突) +} + +// ConsumerOption 消费者配置选项 +type ConsumerOption func(*Consumer) + +// WithPrefetchCount 设置预取数量(并发控制) +func WithPrefetchCount(count int) ConsumerOption { + return func(c *Consumer) { + c.prefetchCount = count + } +} + +// WithAutoAck 设置自动确认 +func WithAutoAck(autoAck bool) ConsumerOption { + return func(c *Consumer) { + c.autoAck = autoAck + } +} + +// WithWorkerCount 设置 worker 数量 +func WithWorkerCount(count int) ConsumerOption { + return func(c *Consumer) { + c.workerCount = count + } +} + +// WithConsumerTag 设置消费者标签 +func WithConsumerTag(tag string) ConsumerOption { + return func(c *Consumer) { + c.consumerTag = tag + } +} + +// NewConsumer 创建消费者 +func NewConsumer(queue string, handler MessageHandler, opts ...ConsumerOption) *Consumer { + c := &Consumer{ + queue: queue, + consumerTag: "", + prefetchCount: 1, // 默认 1 个 + autoAck: false, // 默认手动确认 + handler: handler, + workerCount: 1, // 默认 1 个 worker + } + + // 应用选项 + for _, opt := range opts { + opt(c) + } + + return c +} + +// Start 启动消费者 +func (c *Consumer) Start(ctx context.Context) (err error) { + // 创建可取消的 context + workerCtx, cancel := context.WithCancel(ctx) + c.cancel = cancel + + // 为每个消费者创建独立Channel(避免并发冲突) + conn, err := GetConnection() + if err != nil { + return gerror.Wrap(err, "获取RabbitMQ连接失败") + } + c.channel, err = conn.Channel() + if err != nil { + return gerror.Wrap(err, "创建独立Channel失败") + } + ch := c.channel + + // 声明队列(如果不存在则创建) + // 注意:Queue到Exchange的绑定应由message服务在发送响应时动态创建,或通过运维工具提前配置 + _, err = ch.QueueDeclare( + c.queue, // name + true, // durable(持久化) + false, // autoDelete(不自动删除) + false, // exclusive(非独占) + false, // noWait + nil, // arguments + ) + if err != nil { + return gerror.Newf("声明队列失败: %v", err) + } + + // 设置 QoS(并发控制) + err = ch.Qos( + c.prefetchCount, // prefetchCount: 每个 consumer 最多同时处理的消息数 + 0, // prefetchSize: 0 表示不限制 + false, // global: false 表示仅应用于当前 channel + ) + if err != nil { + return gerror.Newf("设置 QoS 失败: %v", err) + } + + // 开始消费 + msgs, err := ch.Consume( + c.queue, // queue + c.consumerTag, // consumer tag + c.autoAck, // auto-ack + false, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + if err != nil { + return gerror.Newf("开始消费失败: %v", err) + } + + g.Log().Infof(ctx, "消费者已启动: queue=%s, prefetch=%d, workers=%d", + c.queue, c.prefetchCount, c.workerCount) + + // 启动多个 worker + for i := 0; i < c.workerCount; i++ { + go c.worker(workerCtx, i, msgs) + } + + return +} + +// worker 工作协程 +func (c *Consumer) worker(ctx context.Context, workerID int, msgs <-chan amqp.Delivery) { + g.Log().Debugf(ctx, "Worker %d 已启动", workerID) + + for { + select { + case <-ctx.Done(): + // Context 取消,退出 + g.Log().Infof(ctx, "Worker %d 收到停止信号,正在退出", workerID) + return + case msg, ok := <-msgs: + if !ok { + // Channel 关闭,退出 + g.Log().Infof(ctx, "Worker %d 消息通道已关闭,退出", workerID) + return + } + + // 处理消息 + err := c.handler(ctx, msg.Body) + + if err != nil { + g.Log().Errorf(ctx, "Worker %d 处理消息失败: %v", workerID, err) + + // 如果不是自动确认,需要手动 Nack + if !c.autoAck { + // requeue=false: 不重新入队,进入死信队列 + msg.Nack(false, false) + } + } else { + // 处理成功,手动确认 + if !c.autoAck { + msg.Ack(false) + } + + g.Log().Debugf(ctx, "Worker %d 处理消息成功", workerID) + } + } + } +} + +// StartTypedConsumer 启动类型化消费者(自动反序列化) +func StartTypedConsumer[T any]( + ctx context.Context, + queue string, + handler func(ctx context.Context, msg *T) error, + opts ...ConsumerOption, +) error { + // 包装处理函数 + wrappedHandler := func(ctx context.Context, body []byte) error { + var msg T + if err := gjson.DecodeTo(body, &msg); err != nil { + return gerror.Newf("反序列化消息失败: %v", err) + } + + return handler(ctx, &msg) + } + + consumer := NewConsumer(queue, wrappedHandler, opts...) + return consumer.Start(ctx) +} + +// Stop 停止消费者 +func (c *Consumer) Stop(ctx context.Context) { + if c.cancel != nil { + c.cancel() + } + // 关闭独立Channel + if c.channel != nil && !c.channel.IsClosed() { + c.channel.Close() + g.Log().Debugf(ctx, "消费者Channel已关闭: queue=%s", c.queue) + } + g.Log().Infof(ctx, "正在停止消费者: queue=%s", c.queue) + c.cancel = nil +} diff --git a/rabbitmq/consumer_manager.go b/rabbitmq/consumer_manager.go new file mode 100644 index 0000000..54648ce --- /dev/null +++ b/rabbitmq/consumer_manager.go @@ -0,0 +1,175 @@ +// Package rabbitmq 提供 RabbitMQ 消费者管理功能 +// +// 本文件实现消费者统一管理,简化业务层的启动逻辑 +package rabbitmq + +import ( + "context" + "sync" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +// ManagedConsumer 托管消费者(包含启动和停止函数) +type ManagedConsumer struct { + Name string // 消费者名称 + Start func(ctx context.Context) error // 启动函数 + Stop func(ctx context.Context) // 停止函数 +} + +// ConsumerManager RabbitMQ 消费者管理器 +// +// 职责: +// 1. 统一管理所有 RabbitMQ 消费者的生命周期 +// 2. 初始化 RabbitMQ 连接和队列 +// 3. 启动/停止所有消费者 +// 4. 协调消费者的优雅退出 +// +// 使用示例: +// +// mgr := rabbitmq.NewConsumerManager(ctx) +// mgr.Register("响应消费者", responseConsumer.Start, responseConsumer.Stop) +// mgr.Init() +// defer mgr.Stop() +type ConsumerManager struct { + ctx context.Context // 全局上下文 + consumers []*ManagedConsumer // 消费者列表 + wg sync.WaitGroup // 等待所有消费者协程退出 +} + +// NewConsumerManager 创建消费者管理器 +// +// 参数: +// +// ctx: 上下文 +// +// 返回: +// +// *ConsumerManager: 消费者管理器实例 +func NewConsumerManager(ctx context.Context) *ConsumerManager { + return &ConsumerManager{ + ctx: ctx, + consumers: make([]*ManagedConsumer, 0), + } +} + +// Register 注册消费者 +// +// 参数: +// +// name: 消费者名称(用于日志) +// startFunc: 启动函数 +// stopFunc: 停止函数 +// +// 使用示例: +// +// consumer := service.NewResponseConsumer(ctx) +// mgr.Register("响应消费者", consumer.Start, consumer.Stop) +func (cm *ConsumerManager) Register(name string, startFunc func(ctx context.Context) error, stopFunc func(ctx context.Context)) { + cm.consumers = append(cm.consumers, &ManagedConsumer{ + Name: name, + Start: startFunc, + Stop: stopFunc, + }) +} + +// Init 初始化并启动所有消费者 +// +// 执行流程: +// 1. 检查 RabbitMQ 配置(未配置则跳过) +// 2. 初始化 RabbitMQ 连接 +// 3. 声明并绑定队列(响应队列、延时落库队列) +// 4. 异步启动所有已注册的消费者 +// +// 返回: +// +// err: 错误信息,成功返回 nil +// +// 注意: +// - 如果 RabbitMQ 未配置,不会报错,只是跳过初始化 +// - 响应队列初始化失败会导致 Fatal 退出 +// - 延时落库队列失败只会 Warning,不影响主流程 +func (cm *ConsumerManager) Init() (err error) { + // 检查配置文件中是否配置了 RabbitMQ + if g.Cfg().MustGet(cm.ctx, "rabbitmq").IsEmpty() { + glog.Info(cm.ctx, "RabbitMQ未配置,跳过消费者初始化") + return + } + + // 初始化 RabbitMQ 连接(从 config.yml 读取配置) + if err = InitFromConfig(cm.ctx); err != nil { + glog.Fatalf(cm.ctx, "初始化 RabbitMQ 失败: %v", err) + return + } + glog.Info(cm.ctx, "RabbitMQ 连接已初始化") + + // 声明响应Exchange(队列由各消费者自己声明和绑定) + if err = DeclareExchange(cm.ctx, &ExchangeConfig{ + Name: "ragflow.response", + Type: "topic", + Durable: true, + }); err != nil { + glog.Fatalf(cm.ctx, "声明响应Exchange失败: %v", err) + return + } + + // 设置延时落库队列(对话缓存兜底机制) + // 失败不影响主流程,只记录 Warning + if err = SetupDelayedFlushQueue(cm.ctx); err != nil { + glog.Warningf(cm.ctx, "设置延时落库队列失败: %v", err) + } + + // 异步启动所有已注册的消费者 + cm.startConsumers() + return +} + +// startConsumers 启动所有消费者(内部方法) +// +// 实现: +// 1. 遍历已注册的消费者 +// 2. 每个消费者在独立的 goroutine 中运行 +// 3. 使用 WaitGroup 追踪所有消费者协程 +func (cm *ConsumerManager) startConsumers() { + for _, c := range cm.consumers { + cm.wg.Add(1) + go func(consumer *ManagedConsumer) { + defer cm.wg.Done() + if err := consumer.Start(cm.ctx); err != nil { + glog.Errorf(cm.ctx, "%s启动失败: %v", consumer.Name, err) + } + }(c) + glog.Infof(cm.ctx, "%s已启动", c.Name) + } +} + +// Stop 停止所有消费者(优雅退出) +// +// 执行流程: +// 1. 依次停止所有消费者(调用各自的 Stop 方法) +// 2. 等待所有消费者协程退出(WaitGroup.Wait) +// 3. 关闭 RabbitMQ 连接 +// +// 使用场景: +// - 收到 SIGINT/SIGTERM 信号时 +// - 程序正常退出时 +// - defer mgr.Stop() +// +// 注意: +// - Stop 方法会阻塞直到所有消费者完全退出 +// - 确保消费者能正确响应 Stop 信号 +func (cm *ConsumerManager) Stop() { + // 依次停止所有消费者 + for _, c := range cm.consumers { + c.Stop(cm.ctx) + glog.Infof(cm.ctx, "%s已停止", c.Name) + } + + // 等待所有消费者协程退出 + cm.wg.Wait() + + // 关闭 RabbitMQ 连接 + Close(cm.ctx) + glog.Info(cm.ctx, "所有消费者已停止,RabbitMQ连接已关闭") +} diff --git a/rabbitmq/delay.go b/rabbitmq/delay.go new file mode 100644 index 0000000..82faa66 --- /dev/null +++ b/rabbitmq/delay.go @@ -0,0 +1,95 @@ +// Package rabbitmq - RabbitMQ延时消息发布 +package rabbitmq + +import ( + "context" + "time" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + amqp "github.com/rabbitmq/amqp091-go" +) + +// PublishWithDelay 发布延时消息到RabbitMQ +// delaySeconds: 延时秒数 +func PublishWithDelay(ctx context.Context, routingKey string, message interface{}, delaySeconds int) error { + ch, err := GetChannel() + if err != nil { + return gerror.Wrap(err, "获取RabbitMQ通道失败") + } + if ch == nil { + return gerror.New("RabbitMQ通道未初始化") + } + + // 序列化消息 + body, err := gjson.Encode(message) + if err != nil { + return gerror.Wrapf(err, "序列化消息失败") + } + + // 声明延时交换机(x-delayed-message类型) + // 注意:需要RabbitMQ安装延时插件 rabbitmq-plugins enable rabbitmq_delayed_message_exchange + exchangeName := "delayed.exchange" + err = ch.ExchangeDeclare( + exchangeName, + "x-delayed-message", // 延时交换机类型 + true, // durable + false, // auto-deleted + false, // internal + false, // no-wait + amqp.Table{ + "x-delayed-type": "direct", // 底层交换机类型 + }, + ) + if err != nil { + return gerror.Wrapf(err, "声明延时交换机失败") + } + + // 声明队列 + queue, err := ch.QueueDeclare( + routingKey, // 队列名使用routingKey + true, // durable + false, // delete when unused + false, // exclusive + false, // no-wait + nil, + ) + if err != nil { + return gerror.Wrapf(err, "声明队列失败") + } + + // 绑定队列到交换机 + err = ch.QueueBind( + queue.Name, // queue name + routingKey, // routing key + exchangeName, // exchange + false, + nil, + ) + if err != nil { + return gerror.Wrapf(err, "绑定队列失败") + } + + // 发布延时消息 + err = ch.PublishWithContext( + ctx, + exchangeName, // exchange + routingKey, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "application/json", + Body: body, + DeliveryMode: amqp.Persistent, // 持久化消息 + Headers: amqp.Table{ + "x-delay": delaySeconds * 1000, // 延时时间(毫秒) + }, + Timestamp: time.Now(), + }, + ) + if err != nil { + return gerror.Wrapf(err, "发布延时消息失败") + } + + return nil +} diff --git a/rabbitmq/instance.go b/rabbitmq/instance.go new file mode 100644 index 0000000..8ac30dc --- /dev/null +++ b/rabbitmq/instance.go @@ -0,0 +1,59 @@ +package rabbitmq + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/guid" +) + +var ( + instanceId string + instanceOnce sync.Once +) + +// getInstanceId 获取当前实例的唯一标识(单例) +// 优先级:配置文件 > 环境变量 > 容器名/主机名 > 随机UUID +func getInstanceId() string { + instanceOnce.Do(func() { + ctx := context.Background() + + // 1. 优先从配置文件读取(手动指定,最高优先级) + instanceId = g.Cfg().MustGet(ctx, "rabbitmq.instanceName").String() + if instanceId != "" { + return + } + + // 2. 读取环境变量(Docker/K8s部署时设置) + instanceId = os.Getenv("INSTANCE_NAME") + if instanceId != "" { + return + } + + // 3. 使用主机名(Docker容器名/主机名) + hostname, err := os.Hostname() + if err != nil || hostname == "" { + hostname = "unknown" + } + + // 4. 如果主机名是默认值(本地开发),添加随机后缀避免冲突 + if hostname == "localhost" || hostname == "unknown" { + instanceId = hostname + "." + guid.S()[:4] + } else { + instanceId = hostname + } + }) + return instanceId +} + +// GetInstanceQueueName 获取当前实例的响应队列名 +// 格式:{baseQueue}.{hostname}.{uuid8} +func GetInstanceQueueName(baseQueue string) string { + if baseQueue == "" { + baseQueue = "ragflow.response" + } + return fmt.Sprintf("%s.%s", baseQueue, getInstanceId()) +} diff --git a/rabbitmq/publisher.go b/rabbitmq/publisher.go new file mode 100644 index 0000000..d9753b3 --- /dev/null +++ b/rabbitmq/publisher.go @@ -0,0 +1,152 @@ +package rabbitmq + +import ( + "context" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + amqp "github.com/rabbitmq/amqp091-go" +) + +// Publisher 消息发布器 +type Publisher struct { + exchange string + routingKey string +} + +// NewPublisher 创建发布器 +func NewPublisher(exchange, routingKey string) *Publisher { + return &Publisher{ + exchange: exchange, + routingKey: routingKey, + } +} + +// Publish 发布消息(使用默认 routing key) +func (p *Publisher) Publish(ctx context.Context, message interface{}) (err error) { + return p.PublishWithRoutingKey(ctx, p.routingKey, message) +} + +// PublishWithRoutingKey 发布消息(指定 routing key) +func (p *Publisher) PublishWithRoutingKey(ctx context.Context, routingKey string, message interface{}) (err error) { + ch, err := GetChannel() + if err != nil { + return err + } + + // 序列化消息 + body, err := gjson.Encode(message) + if err != nil { + return gerror.Newf("消息序列化失败: %v", err) + } + + // 发布消息 + err = ch.PublishWithContext( + ctx, + p.exchange, // exchange + routingKey, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + DeliveryMode: amqp.Persistent, // 持久化 + ContentType: "application/json", + Body: body, + }, + ) + + if err != nil { + g.Log().Errorf(ctx, "发布消息失败: exchange=%s, routingKey=%s, err=%v", + p.exchange, routingKey, err) + return err + } + + g.Log().Debugf(ctx, "消息发布成功: exchange=%s, routingKey=%s", + p.exchange, routingKey) + + return +} + +// PublishDelayed 发布延时消息 +// delaySeconds: 延时秒数 +func (p *Publisher) PublishDelayed(ctx context.Context, message interface{}, delaySeconds int) (err error) { + ch, err := GetChannel() + if err != nil { + return err + } + + // 序列化消息 + body, err := gjson.Encode(message) + if err != nil { + return gerror.Newf("消息序列化失败: %v", err) + } + + // 发布延时消息(需要 rabbitmq_delayed_message_exchange 插件) + err = ch.PublishWithContext( + ctx, + p.exchange, // exchange(必须是 x-delayed-message 类型) + p.routingKey, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + DeliveryMode: amqp.Persistent, + ContentType: "application/json", + Body: body, + Headers: amqp.Table{ + "x-delay": delaySeconds * 1000, // 延时(毫秒) + }, + }, + ) + + if err != nil { + g.Log().Errorf(ctx, "发布延时消息失败: exchange=%s, routingKey=%s, delay=%ds, err=%v", + p.exchange, p.routingKey, delaySeconds, err) + return err + } + + g.Log().Debugf(ctx, "延时消息发布成功: exchange=%s, routingKey=%s, delay=%ds", + p.exchange, p.routingKey, delaySeconds) + + return +} + +// PublishBatch 批量发布消息 +func (p *Publisher) PublishBatch(ctx context.Context, messages []interface{}) (err error) { + if len(messages) == 0 { + return + } + + ch, err := GetChannel() + if err != nil { + return err + } + + for i, message := range messages { + body, err := gjson.Encode(message) + if err != nil { + g.Log().Errorf(ctx, "消息 %d 序列化失败: %v", i, err) + continue + } + + err = ch.PublishWithContext( + ctx, + p.exchange, + p.routingKey, + false, + false, + amqp.Publishing{ + DeliveryMode: amqp.Persistent, + ContentType: "application/json", + Body: body, + }, + ) + + if err != nil { + g.Log().Errorf(ctx, "消息 %d 发布失败: %v", i, err) + continue + } + } + + g.Log().Infof(ctx, "批量发布完成: 共 %d 条消息", len(messages)) + return +} diff --git a/rabbitmq/queue_setup.go b/rabbitmq/queue_setup.go new file mode 100644 index 0000000..dbb3130 --- /dev/null +++ b/rabbitmq/queue_setup.go @@ -0,0 +1,111 @@ +// Package rabbitmq 提供 RabbitMQ 队列初始化的封装方法 +// +// 本文件包含常用队列的声明和绑定逻辑,简化业务层的队列配置代码 +package rabbitmq + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +// SetupResponseQueue 初始化 RAGFlow 响应队列 +// +// 功能: +// 1. 声明持久化队列(从配置文件读取队列名,默认 ragflow.response.queue) +// 2. 绑定到 ragflow.response Exchange(Topic 类型) +// 3. 使用通配符 # 匹配所有 routing key(userId) +// +// 参数: +// +// ctx: 上下文 +// +// 返回: +// +// err: 错误信息,成功返回 nil +// +// 配置示例(config.yml): +// +// rabbitmq: +// responseQueue: "ragflow.response.queue" # 可选,默认值 +func SetupResponseQueue(ctx context.Context) (err error) { + // 从配置文件读取队列名(支持每个开发者配置独立队列名) + responseQueue := g.Cfg().MustGet(ctx, "rabbitmq.responseQueue", "ragflow.response.queue").String() + + // 声明持久化队列(服务器重启后队列仍存在) + if err = DeclareQueue(ctx, &QueueConfig{ + Name: responseQueue, + Durable: true, // 持久化,防止数据丢失 + }); err != nil { + glog.Errorf(ctx, "声明响应队列失败: %v", err) + return + } + + // 绑定队列到 Exchange + // Exchange 类型为 topic,routing key 使用通配符 # 匹配所有 userId + if err = BindQueue(ctx, &BindingConfig{ + Queue: responseQueue, + Exchange: "ragflow.response", // RAGFlow 响应 Exchange + RoutingKey: "#", // 通配符,匹配所有消息 + }); err != nil { + glog.Errorf(ctx, "绑定响应队列失败: %v", err) + return + } + + glog.Infof(ctx, "响应队列已绑定: %s -> ragflow.response (routingKey=#)", responseQueue) + return +} + +// SetupDelayedFlushQueue 初始化延时落库队列 +// +// 功能: +// 1. 声明延时 Exchange(x-delayed-message 插件) +// 2. 声明持久化队列 conversation.flush.queue +// 3. 绑定队列到延时 Exchange +// +// 用途: +// +// 对话缓存延时落库机制的兜底策略 +// 当对话少于5句时,10分钟后触发延时消息将缓存写入MongoDB +// +// 参数: +// +// ctx: 上下文 +// +// 返回: +// +// err: 错误信息,成功返回 nil +// +// 相关: +// - service/conversation_service.go: handleResponse() +// - service/conversation_service.go: handleDelayedFlush() +func SetupDelayedFlushQueue(ctx context.Context) (err error) { + // 声明延时 Exchange(需要 RabbitMQ 安装 x-delayed-message 插件) + if err = SetupDelayExchange(ctx, "conversation.flush.delayed"); err != nil { + glog.Warningf(ctx, "声明延时落库 Exchange 失败: %v", err) + return + } + + // 声明持久化队列 + if err = DeclareQueue(ctx, &QueueConfig{ + Name: "conversation.flush.queue", + Durable: true, // 持久化,防止延时消息丢失 + }); err != nil { + glog.Warningf(ctx, "声明延时落库 Queue 失败: %v", err) + return + } + + // 绑定队列到延时 Exchange + if err = BindQueue(ctx, &BindingConfig{ + Queue: "conversation.flush.queue", + Exchange: "conversation.flush.delayed", + RoutingKey: "flush", // 延时落库消息的 routing key + }); err != nil { + glog.Warningf(ctx, "绑定延时落库 Queue 失败: %v", err) + return + } + + glog.Info(ctx, "延时落库队列已配置") + return +} diff --git a/rabbitmq/rabbitmq_client.go b/rabbitmq/rabbitmq_client.go new file mode 100644 index 0000000..d6a4585 --- /dev/null +++ b/rabbitmq/rabbitmq_client.go @@ -0,0 +1,210 @@ +package rabbitmq + +import ( + "context" + "sync" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + amqp "github.com/rabbitmq/amqp091-go" +) + +var ( + conn *amqp.Connection + channel *amqp.Channel + rabbitmqOnce sync.Once + rabbitmqMu sync.RWMutex + closeWatcher chan struct{} // 用于停止监听 goroutine + watcherStarted bool // 防止重复启动监听 +) + +// Config RabbitMQ 配置 +type Config struct { + Host string + Port int + Username string + Password string + VHost string +} + +// Init 初始化 RabbitMQ 连接 +func Init(ctx context.Context, cfg *Config) error { + var err error + rabbitmqOnce.Do(func() { + // 构建连接字符串 + url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + gconv.String(cfg.Port) + "/" + cfg.VHost + + // 创建连接 + conn, err = amqp.Dial(url) + if err != nil { + g.Log().Errorf(ctx, "RabbitMQ 连接失败: %v", err) + return + } + + // 创建 Channel + channel, err = conn.Channel() + if err != nil { + g.Log().Errorf(ctx, "创建 RabbitMQ Channel 失败: %v", err) + return + } + + // 初始化关闭监听器 + closeWatcher = make(chan struct{}) + + // 监听连接关闭(只启动一次) + if !watcherStarted { + go handleConnectionClose(ctx) + watcherStarted = true + } + + g.Log().Info(ctx, "RabbitMQ 连接成功") + }) + + return err +} + +// InitFromConfig 从配置文件初始化 +func InitFromConfig(ctx context.Context) error { + cfg := &Config{ + Host: g.Cfg().MustGet(ctx, "rabbitmq.host").String(), + Port: g.Cfg().MustGet(ctx, "rabbitmq.port").Int(), + Username: g.Cfg().MustGet(ctx, "rabbitmq.username").String(), + Password: g.Cfg().MustGet(ctx, "rabbitmq.password").String(), + VHost: g.Cfg().MustGet(ctx, "rabbitmq.vhost", "/").String(), + } + + return Init(ctx, cfg) +} + +// GetChannel 获取 Channel +func GetChannel() (*amqp.Channel, error) { + rabbitmqMu.RLock() + defer rabbitmqMu.RUnlock() + + if channel == nil || channel.IsClosed() { + return nil, gerror.New("RabbitMQ Channel 未初始化或已关闭") + } + + return channel, nil +} + +// GetConnection 获取连接 +func GetConnection() (*amqp.Connection, error) { + rabbitmqMu.RLock() + defer rabbitmqMu.RUnlock() + + if conn == nil || conn.IsClosed() { + return nil, gerror.New("RabbitMQ 连接未初始化或已关闭") + } + + return conn, nil +} + +// handleConnectionClose 监听连接关闭并重连 +func handleConnectionClose(ctx context.Context) { + for { + // 检查是否需要停止监听 + select { + case <-closeWatcher: + g.Log().Info(ctx, "停止监听 RabbitMQ 连接状态") + return + default: + } + + rabbitmqMu.RLock() + currentConn := conn + rabbitmqMu.RUnlock() + + if currentConn == nil { + return + } + + // 创建关闭通知 channel + closeErr := make(chan *amqp.Error, 1) + currentConn.NotifyClose(closeErr) + + // 等待连接关闭或停止信号 + select { + case err := <-closeErr: + if err != nil { + g.Log().Errorf(ctx, "RabbitMQ 连接关闭: %v,尝试重连...", err) + reconnect(ctx) + } + case <-closeWatcher: + g.Log().Info(ctx, "停止监听 RabbitMQ 连接状态") + return + } + } +} + +// reconnect 重新连接 +func reconnect(ctx context.Context) { + rabbitmqMu.Lock() + defer rabbitmqMu.Unlock() + + for i := 0; i < 10; i++ { + time.Sleep(time.Duration(i+1) * time.Second) + + cfg := &Config{ + Host: g.Cfg().MustGet(ctx, "rabbitmq.host").String(), + Port: g.Cfg().MustGet(ctx, "rabbitmq.port").Int(), + Username: g.Cfg().MustGet(ctx, "rabbitmq.username").String(), + Password: g.Cfg().MustGet(ctx, "rabbitmq.password").String(), + VHost: g.Cfg().MustGet(ctx, "rabbitmq.vhost", "/").String(), + } + + url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + gconv.String(cfg.Port) + "/" + cfg.VHost + + var err error + conn, err = amqp.Dial(url) + if err != nil { + g.Log().Errorf(ctx, "重连失败 (尝试 %d/10): %v", i+1, err) + continue + } + + channel, err = conn.Channel() + if err != nil { + g.Log().Errorf(ctx, "创建 Channel 失败 (尝试 %d/10): %v", i+1, err) + continue + } + + g.Log().Info(ctx, "RabbitMQ 重连成功") + // 不再重复启动监听 goroutine + return + } + + g.Log().Fatal(ctx, "RabbitMQ 重连失败,已达到最大重试次数") +} + +// Close 关闭连接 +func Close(ctx context.Context) (err error) { + rabbitmqMu.Lock() + defer rabbitmqMu.Unlock() + + // 停止监听 goroutine + if closeWatcher != nil { + close(closeWatcher) + closeWatcher = nil + } + + if channel != nil { + if err = channel.Close(); err != nil { + g.Log().Errorf(ctx, "关闭 RabbitMQ Channel 失败: %v", err) + } + channel = nil + } + + if conn != nil { + if err = conn.Close(); err != nil { + g.Log().Errorf(ctx, "关闭 RabbitMQ 连接失败: %v", err) + return + } + conn = nil + } + + watcherStarted = false + g.Log().Info(ctx, "RabbitMQ 连接已关闭") + return +} diff --git a/rabbitmq/setup.go b/rabbitmq/setup.go new file mode 100644 index 0000000..47793fd --- /dev/null +++ b/rabbitmq/setup.go @@ -0,0 +1,231 @@ +package rabbitmq + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + amqp "github.com/rabbitmq/amqp091-go" +) + +// QueueConfig 队列配置 +type QueueConfig struct { + Name string + Durable bool // 持久化 + AutoDelete bool // 自动删除 + Exclusive bool // 排他 + Args amqp.Table // 额外参数 +} + +// ExchangeConfig Exchange 配置 +type ExchangeConfig struct { + Name string + Type string // direct/topic/fanout/x-delayed-message + Durable bool + AutoDelete bool + Args amqp.Table +} + +// BindingConfig 绑定配置 +type BindingConfig struct { + Queue string + Exchange string + RoutingKey string + Args amqp.Table +} + +// DeclareQueue 声明队列 +func DeclareQueue(ctx context.Context, cfg *QueueConfig) (err error) { + ch, err := GetChannel() + if err != nil { + return err + } + + _, err = ch.QueueDeclare( + cfg.Name, + cfg.Durable, + cfg.AutoDelete, + cfg.Exclusive, + false, // no-wait + cfg.Args, + ) + + if err != nil { + g.Log().Errorf(ctx, "声明队列失败: %s, err=%v", cfg.Name, err) + return err + } + + g.Log().Infof(ctx, "队列声明成功: %s", cfg.Name) + return +} + +// DeclareExchange 声明 Exchange +func DeclareExchange(ctx context.Context, cfg *ExchangeConfig) (err error) { + ch, err := GetChannel() + if err != nil { + return err + } + + err = ch.ExchangeDeclare( + cfg.Name, + cfg.Type, + cfg.Durable, + cfg.AutoDelete, + false, // internal + false, // no-wait + cfg.Args, + ) + + if err != nil { + g.Log().Errorf(ctx, "声明 Exchange 失败: %s, err=%v", cfg.Name, err) + return err + } + + g.Log().Infof(ctx, "Exchange 声明成功: %s (type=%s)", cfg.Name, cfg.Type) + return +} + +// BindQueue 绑定队列到 Exchange +func BindQueue(ctx context.Context, cfg *BindingConfig) (err error) { + ch, err := GetChannel() + if err != nil { + return err + } + + err = ch.QueueBind( + cfg.Queue, + cfg.RoutingKey, + cfg.Exchange, + false, // no-wait + cfg.Args, + ) + + if err != nil { + g.Log().Errorf(ctx, "绑定队列失败: queue=%s, exchange=%s, routingKey=%s, err=%v", + cfg.Queue, cfg.Exchange, cfg.RoutingKey, err) + return err + } + + g.Log().Infof(ctx, "队列绑定成功: queue=%s → exchange=%s (routingKey=%s)", + cfg.Queue, cfg.Exchange, cfg.RoutingKey) + return +} + +// SetupDelayExchange 设置延时 Exchange(需要 rabbitmq_delayed_message_exchange 插件) +func SetupDelayExchange(ctx context.Context, exchangeName string) error { + return DeclareExchange(ctx, &ExchangeConfig{ + Name: exchangeName, + Type: "x-delayed-message", + Durable: true, + Args: amqp.Table{ + "x-delayed-type": "direct", + }, + }) +} + +// SetupDeadLetterQueue 设置死信队列 +func SetupDeadLetterQueue(ctx context.Context, queueName, exchangeName string) error { + // 1. 声明死信 Exchange + err := DeclareExchange(ctx, &ExchangeConfig{ + Name: exchangeName, + Type: "direct", + Durable: true, + }) + if err != nil { + return err + } + + // 2. 声明死信队列 + err = DeclareQueue(ctx, &QueueConfig{ + Name: queueName, + Durable: true, + }) + if err != nil { + return err + } + + // 3. 绑定 + return BindQueue(ctx, &BindingConfig{ + Queue: queueName, + Exchange: exchangeName, + RoutingKey: queueName, + }) +} + +// SetupQueueWithDLX 创建带死信队列的普通队列 +func SetupQueueWithDLX(ctx context.Context, queueName, dlxExchange, dlxRoutingKey string) error { + return DeclareQueue(ctx, &QueueConfig{ + Name: queueName, + Durable: true, + Args: amqp.Table{ + "x-dead-letter-exchange": dlxExchange, + "x-dead-letter-routing-key": dlxRoutingKey, + }, + }) +} + +// SetupBasicTopology 设置基础拓扑(适用于小红书客服场景) +func SetupBasicTopology(ctx context.Context) (err error) { + // 1. 声明普通 Exchange + err = DeclareExchange(ctx, &ExchangeConfig{ + Name: "ragflow_exchange", + Type: "direct", + Durable: true, + }) + if err != nil { + return err + } + + // 2. 声明延时 Exchange + err = SetupDelayExchange(ctx, "delay_exchange") + if err != nil { + return gerror.Newf("延时 Exchange 声明失败(可能未安装插件): %v", err) + } + + // 3. 声明死信队列 + err = SetupDeadLetterQueue(ctx, "dead_letter_queue", "dlx_exchange") + if err != nil { + return err + } + + // 4. 声明业务队列 + queues := []struct { + name string + dlx bool // 是否需要死信队列 + }{ + {"ragflow_request_queue", true}, + {"follow_up_queue", true}, + {"archive_queue", true}, + } + + for _, q := range queues { + if q.dlx { + err = SetupQueueWithDLX(ctx, q.name, "dlx_exchange", "dead_letter_queue") + } else { + err = DeclareQueue(ctx, &QueueConfig{ + Name: q.name, + Durable: true, + }) + } + if err != nil { + return err + } + } + + // 5. 绑定队列 + bindings := []BindingConfig{ + {Queue: "ragflow_request_queue", Exchange: "ragflow_exchange", RoutingKey: "ragflow_request_queue"}, + {Queue: "follow_up_queue", Exchange: "delay_exchange", RoutingKey: "follow_up_queue"}, + {Queue: "archive_queue", Exchange: "delay_exchange", RoutingKey: "archive_queue"}, + } + + for _, b := range bindings { + err = BindQueue(ctx, &b) + if err != nil { + return err + } + } + + g.Log().Info(ctx, "RabbitMQ 拓扑结构设置完成") + return +} diff --git a/rag/eino/base_task.go b/rag/eino/base_task.go new file mode 100644 index 0000000..b883341 --- /dev/null +++ b/rag/eino/base_task.go @@ -0,0 +1,49 @@ +package eino + +import ( + "time" + + "gitea.com/red-future/common/beans" +) + +// BaseTask 任务基类 - MongoDB版本 +type BaseTask struct { + beans.MongoBaseDO `bson:",inline"` + // 任务信息 + TaskType TaskType `bson:"taskType" json:"taskType"` + Status TaskStatus `bson:"status" json:"status"` + Priority TaskPriority `bson:"priority,omitempty" json:"priority,omitempty"` + // 进度 + TotalItems int64 `bson:"totalItems" json:"totalItems"` + ProcessedItems int64 `bson:"processedItems" json:"processedItems"` + Progress float64 `bson:"progress" json:"progress"` + // 结果 + StartTime *time.Time `bson:"startTime" json:"startTime"` + EndTime *time.Time `bson:"endTime,omitempty" json:"endTime,omitempty"` + Duration int64 `bson:"duration,omitempty" json:"duration,omitempty"` + SuccessCount int64 `bson:"successCount" json:"successCount"` + FailCount int64 `bson:"failCount" json:"failCount"` + // 其他 + Executor string `bson:"executor,omitempty" json:"executor,omitempty"` +} + +// SQLBaseTask 任务基类 - SQL版本 +type SQLBaseTask struct { + beans.SQLBaseDO + // 任务信息 + TaskType TaskType `json:"taskType"` + Status TaskStatus `json:"status"` + Priority TaskPriority `json:"priority,omitempty"` + // 进度 + TotalItems int64 `json:"totalItems"` + ProcessedItems int64 `json:"processedItems"` + Progress float64 `json:"progress"` + // 结果 + StartTime *time.Time `json:"startTime"` + EndTime *time.Time `json:"endTime,omitempty"` + Duration int64 `json:"duration,omitempty"` + SuccessCount int64 `json:"successCount"` + FailCount int64 `json:"failCount"` + // 其他 + Executor string `json:"executor,omitempty"` +} diff --git a/rag/eino/embedding_qwen.go b/rag/eino/embedding_qwen.go new file mode 100644 index 0000000..9496874 --- /dev/null +++ b/rag/eino/embedding_qwen.go @@ -0,0 +1,273 @@ +/* + * Copyright 2024 Red Future Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eino + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/cloudwego/eino/callbacks" + "github.com/cloudwego/eino/components" + "github.com/cloudwego/eino/components/embedding" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/util/gconv" +) + +var ( + // 千问API默认配置 + defaultBaseURL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding" + defaultTimeout = 10 * time.Minute + defaultRetryTimes = 2 +) + +type QwenEmbeddingConfig struct { + // Timeout specifies the maximum duration to wait for API responses + // Optional. Default: 10 minutes + Timeout *time.Duration `json:"timeout"` + + // HTTPClient specifies the client to send HTTP requests. + // Optional. Default &http.Client{Timeout: Timeout} + HTTPClient *http.Client `json:"http_client"` + + // RetryTimes specifies the number of retry attempts for failed API calls + // Optional. Default: 2 + RetryTimes *int `json:"retry_times"` + + // BaseURL specifies the base URL for Qwen DashScope service + // Optional. Default: "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding" + BaseURL string `json:"base_url"` + + // APIKey specifies the API Key for authentication + // Required + APIKey string `json:"api_key"` + + // Model specifies the model name for Qwen embedding + // Required. Examples: "text-embedding-v2", "text-embedding-v3" + Model string `json:"model"` + + // TextType specifies the type of text: "document" or "query" + // Optional. Default: "document" + TextType string `json:"text_type"` + + // MaxConcurrentRequests specifies the maximum number of concurrent requests allowed + // Optional. Default: 5 + MaxConcurrentRequests *int `json:"max_concurrent_requests"` +} + +type QwenEmbedder struct { + client *gclient.Client + conf *QwenEmbeddingConfig +} + +// EmbeddingRequest 千问embedding请求结构 +type EmbeddingRequest struct { + Model string `json:"model"` + Input struct { + Texts []string `json:"texts"` + } `json:"input"` + Parameters struct { + TextType string `json:"text_type,omitempty"` + } `json:"parameters,omitempty"` +} + +// EmbeddingResponse 千问embedding响应结构 +type EmbeddingResponse struct { + Output struct { + Embeddings []struct { + TextIndex int `json:"text_index"` + Embedding []float64 `json:"embedding"` + } `json:"embeddings"` + } `json:"output"` + Usage struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + RequestID string `json:"request_id"` +} + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API Error: %s - %s (RequestID: %s)", e.Code, e.Message, e.RequestID) +} + +func buildQwenClient(config *QwenEmbeddingConfig) *gclient.Client { + if len(config.BaseURL) == 0 { + config.BaseURL = defaultBaseURL + } + if config.Timeout == nil { + config.Timeout = &defaultTimeout + } + if config.RetryTimes == nil { + defaultRetryTimes := 2 + config.RetryTimes = &defaultRetryTimes + } + if len(config.TextType) == 0 { + config.TextType = "document" + } + if config.MaxConcurrentRequests == nil { + defaultMaxConcurrentRequests := 5 + config.MaxConcurrentRequests = &defaultMaxConcurrentRequests + } + + client := g.Client() + client.SetTimeout(*config.Timeout) + + return client +} + +func NewQwenEmbedder(ctx context.Context, config *QwenEmbeddingConfig) (*QwenEmbedder, error) { + if len(config.APIKey) == 0 { + return nil, fmt.Errorf("[Qwen] APIKey is required") + } + if len(config.Model) == 0 { + return nil, fmt.Errorf("[Qwen] Model is required") + } + + client := buildQwenClient(config) + + return &QwenEmbedder{ + client: client, + conf: config, + }, nil +} + +func (e *QwenEmbedder) EmbedStrings(ctx context.Context, texts []string, opts ...embedding.Option) ( + [][]float64, error) { + + if len(texts) == 0 { + return nil, fmt.Errorf("[Qwen] texts cannot be empty") + } + + options := embedding.GetCommonOptions(&embedding.Options{ + Model: &e.conf.Model, + }, opts...) + + conf := &embedding.Config{ + Model: dereferenceOrZero(options.Model), + } + + ctx = callbacks.EnsureRunInfo(ctx, e.GetType(), components.ComponentOfEmbedding) + ctx = callbacks.OnStart(ctx, &embedding.CallbackInput{ + Texts: texts, + Config: conf, + }) + defer func() { + if err := recover(); err != nil { + callbacks.OnError(ctx, fmt.Errorf("[Qwen] panic: %v", err)) + } + }() + + var usage *embedding.TokenUsage + var embeddings [][]float64 + var err error + + // 调用千问API获取embedding + embeddings, usage, err = e.callEmbeddingAPI(ctx, texts) + if err != nil { + callbacks.OnError(ctx, err) + return nil, err + } + + callbacks.OnEnd(ctx, &embedding.CallbackOutput{ + Embeddings: embeddings, + Config: conf, + TokenUsage: usage, + }) + + return embeddings, nil +} + +func (e *QwenEmbedder) callEmbeddingAPI(ctx context.Context, texts []string) ([][]float64, *embedding.TokenUsage, error) { + // 构建请求 + var req EmbeddingRequest + req.Model = e.conf.Model + req.Input.Texts = texts + req.Parameters.TextType = e.conf.TextType + + // 调用API + client := e.client.Clone() + client.SetHeader("Authorization", "Bearer "+e.conf.APIKey) + client.SetHeader("Content-Type", "application/json") + client.SetTimeout(*e.conf.Timeout) + + resp, err := client.Post(ctx, e.conf.BaseURL, req) + if err != nil { + return nil, nil, fmt.Errorf("[Qwen] HTTP request error: %w", err) + } + + defer resp.Close() + + // 检查状态码 + if resp.StatusCode != http.StatusOK { + var errResp APIError + result := resp.ReadAll() + if err = gconv.Struct(result, &errResp); err == nil && errResp.Code != "" { + return nil, nil, &errResp + } + return nil, nil, fmt.Errorf("[Qwen] HTTP status error: %d", resp.StatusCode) + } + + // 解析响应 + var apiResp EmbeddingResponse + result := resp.ReadAll() + if err = gconv.Struct(result, &apiResp); err != nil { + return nil, nil, fmt.Errorf("[Qwen] parse response error: %w", err) + } + + // 解析响应结果 + embeddings := make([][]float64, len(texts)) + for _, emb := range apiResp.Output.Embeddings { + if emb.TextIndex >= 0 && emb.TextIndex < len(embeddings) { + embeddings[emb.TextIndex] = emb.Embedding + } + } + + usage := &embedding.TokenUsage{ + TotalTokens: apiResp.Usage.TotalTokens, + } + + g.Log().Debugf(ctx, "[Qwen] Embedding success: request_id=%s, total_tokens=%d", apiResp.RequestID, usage.TotalTokens) + + return embeddings, usage, nil +} + +func (e *QwenEmbedder) GetType() string { + return getType() +} + +func (e *QwenEmbedder) IsCallbacksEnabled() bool { + return true +} + +func getType() string { + return "Qwen" +} + +func dereferenceOrZero[T any](v *T) T { + if v == nil { + var t T + return t + } + return *v +} diff --git a/rag/eino/priority_enum.go b/rag/eino/priority_enum.go new file mode 100644 index 0000000..371706b --- /dev/null +++ b/rag/eino/priority_enum.go @@ -0,0 +1,11 @@ +package eino + +// TaskPriority 任务优先级 +type TaskPriority string + +const ( + TaskPriorityLow TaskPriority = "low" // 低优先级 + TaskPriorityMedium TaskPriority = "medium" // 中优先级 + TaskPriorityHigh TaskPriority = "high" // 高优先级 + TaskPriorityUrgent TaskPriority = "urgent" // 紧急 +) diff --git a/rag/eino/status_enum.go b/rag/eino/status_enum.go new file mode 100644 index 0000000..6e12daf --- /dev/null +++ b/rag/eino/status_enum.go @@ -0,0 +1,12 @@ +package eino + +// TaskStatus 任务状态 +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" // 待处理 + TaskStatusRunning TaskStatus = "running" // 运行中 + TaskStatusCompleted TaskStatus = "completed" // 已完成 + TaskStatusFailed TaskStatus = "failed" // 失败 + TaskStatusCancelled TaskStatus = "cancelled" // 已取消 +) diff --git a/rag/eino/task_type.go b/rag/eino/task_type.go new file mode 100644 index 0000000..0ba5a64 --- /dev/null +++ b/rag/eino/task_type.go @@ -0,0 +1,14 @@ +package eino + +// TaskType 任务类型 +type TaskType string + +const ( + TaskTypeDocumentIngestion TaskType = "document_ingestion" // 文档摄入任务 + TaskTypeVectorIngestion TaskType = "vector_ingestion" // 向量摄入任务 + TaskTypeIndexCreation TaskType = "index_creation" // 索引创建任务 + TaskTypeQAProcessing TaskType = "qa_processing" // 问答处理任务 + TaskTypeKnowledgeConstruction TaskType = "knowledge_construction" // 知识库构建任务 + TaskTypeGraphBuilding TaskType = "graph_building" // 图谱构建任务 + TaskTypeKnowledgeSync TaskType = "knowledge_sync" // 知识同步任务 +) diff --git a/ragflow/agent.go b/ragflow/agent.go new file mode 100644 index 0000000..e389e59 --- /dev/null +++ b/ragflow/agent.go @@ -0,0 +1,141 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" +) + +// Agent AGENT 管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#agent-管理 + +// Agent Agent 结构体 +type Agent struct { + ID string `json:"id"` // Agent ID + Title string `json:"title"` // Agent 标题 + Description string `json:"description"` // Agent 描述 + Avatar string `json:"avatar"` // 头像(Base64 编码) + CanvasType string `json:"canvas_type"` // 画布类型 + CreateDate string `json:"create_date"` // 创建日期(格式化字符串) + CreateTime int64 `json:"create_time"` // 创建时间(Unix 时间戳) + UpdateDate string `json:"update_date"` // 更新日期(格式化字符串) + UpdateTime int64 `json:"update_time"` // 更新时间(Unix 时间戳) + UserID string `json:"user_id"` // 用户 ID + DSL map[string]interface{} `json:"dsl"` // Canvas DSL 对象,定义 Agent 的工作流 +} + +// CreateAgentReq 创建 Agent 请求 +type CreateAgentReq struct { + Title string `json:"title"` // 必需 + Description string `json:"description,omitempty"` // 可选,默认为 None + DSL map[string]interface{} `json:"dsl"` // 必需,Canvas DSL 对象 +} + +// UpdateAgentReq 更新 Agent 请求 +type UpdateAgentReq struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + DSL map[string]interface{} `json:"dsl,omitempty"` +} + +// ListAgentsReq 列出 Agent 请求 +type ListAgentsReq struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + OrderBy string `json:"orderby,omitempty"` + Desc bool `json:"desc,omitempty"` + Title string `json:"title,omitempty"` + ID string `json:"id,omitempty"` +} + +// ListAgentsRes 列出 Agent 响应 +// 注意:API 不返回 total 字段,仅返回 data 数组 +type ListAgentsRes struct { + Code int `json:"code"` // 状态码,0 表示成功 + Data []*Agent `json:"data"` // Agent 列表 +} + +// CreateAgent 创建 Agent +// POST /api/v1/agents +func (c *Client) CreateAgent(ctx context.Context, req *CreateAgentReq) (err error) { + var res CommonResponse + if err = c.request(ctx, "POST", "/api/v1/agents", req, &res); err != nil { + return gerror.Newf("create agent failed: %v", err) + } + if !res.IsSuccess() { + return gerror.Newf("create agent failed: %s", res.Message) + } + return +} + +// UpdateAgent 更新 Agent +// PUT /api/v1/agents/{agent_id} +func (c *Client) UpdateAgent(ctx context.Context, agentID string, req *UpdateAgentReq) (err error) { + path := "/api/v1/agents/" + agentID + var res CommonResponse + if err = c.request(ctx, "PUT", path, req, &res); err != nil { + return gerror.Newf("update agent failed: %v", err) + } + if !res.IsSuccess() { + return gerror.Newf("update agent failed: %s", res.Message) + } + return +} + +// DeleteAgent 删除 Agent +// DELETE /api/v1/agents/{agent_id} +func (c *Client) DeleteAgent(ctx context.Context, agentID string) (err error) { + path := "/api/v1/agents/" + agentID + var res CommonResponse + // 官方文档要求传空对象,不是 nil + if err = c.request(ctx, "DELETE", path, map[string]interface{}{}, &res); err != nil { + return gerror.Newf("delete agent failed: %v", err) + } + if !res.IsSuccess() { + return gerror.Newf("delete agent failed: %s", res.Message) + } + return +} + +// ListAgents 列出 Agent +// GET /api/v1/agents +func (c *Client) ListAgents(ctx context.Context, req *ListAgentsReq) (*ListAgentsRes, error) { + path := "/api/v1/agents" + if req != nil { + params := map[string]interface{}{} + if req.Page > 0 { + params["page"] = req.Page + } + if req.PageSize > 0 { + params["page_size"] = req.PageSize + } + if req.OrderBy != "" { + params["orderby"] = req.OrderBy + } + if req.Desc { + params["desc"] = "true" + } else { + params["desc"] = "false" + } + if req.Title != "" { + params["title"] = req.Title + } + if req.ID != "" { + params["id"] = req.ID + } + + query := buildQueryString(params) + if query != "" { + path += "?" + query + } + } + + var res ListAgentsRes + if err := c.request(ctx, "GET", path, nil, &res); err != nil { + return nil, gerror.Newf("list agents failed: %v", err) + } + if res.Code != 0 { + return nil, gerror.Newf("list agents failed: code=%d", res.Code) + } + return &res, nil +} diff --git a/ragflow/chat.go b/ragflow/chat.go new file mode 100644 index 0000000..bdce50b --- /dev/null +++ b/ragflow/chat.go @@ -0,0 +1,198 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" +) + +// CreateChatReq 创建对话配置请求 +type CreateChatReq struct { + Name string `json:"name"` // 对话配置名称(助理姓名) + Description string `json:"description,omitempty"` // 助理描述 + DatasetIds []string `json:"dataset_ids"` // 关联的知识库ID列表 + Prompt *PromptConfig `json:"prompt"` // 提示词配置 + Llm *Llm `json:"llm,omitempty"` // LLM配置 +} + +// PromptConfig 提示词配置 +type PromptConfig struct { + Prompt string `json:"prompt"` // 提示词内容 + SimilarityThreshold float64 `json:"similarity_threshold"` // 相似度阈值 + KeywordsSimilarityWeight float64 `json:"keywords_similarity_weight"` // 关键词相似度权重 + TopN int `json:"top_n"` // 返回顶部N个chunk + EmptyResponse string `json:"empty_response"` // 无匹配时回复(必须显式传入空字符串才能让LLM自由发挥,不传入会使用RAGFlow默认提示词) + Opener string `json:"opener,omitempty"` // 开场白 + ShowQuote bool `json:"show_quote,omitempty"` // 是否显示引用 + Variables []map[string]interface{} `json:"variables,omitempty"` // 变量列表 +} + +// CreateChatRes 创建对话配置响应 +type CreateChatRes struct { + ChatId string `json:"id"` // 对话配置ID +} + +// UpdateChatReq 更新对话配置请求 +type UpdateChatReq struct { + Name string `json:"name,omitempty"` // 对话配置名称 + Description string `json:"description,omitempty"` // 对话描述 + DatasetIds []string `json:"dataset_ids,omitempty"` // 关联的知识库ID列表(RAGFlow API使用下划线格式) + Prompt *PromptConfig `json:"prompt,omitempty"` // 提示词配置 +} + +// 聊天助手管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#聊天助手管理 + +// Chat 聊天助手结构体 +type Chat struct { + Id string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + DatasetIds []string `json:"dataset_ids"` + Llm Llm `json:"llm"` + Prompt Prompt `json:"prompt"` + Description string `json:"description"` + DoRefer string `json:"do_refer"` + Language string `json:"language"` + PromptType string `json:"prompt_type"` + Status string `json:"status"` + TenantId string `json:"tenant_id"` + TopK int `json:"top_k"` + CreateDate string `json:"create_date"` + CreateTime int64 `json:"create_time"` + UpdateDate string `json:"update_date"` + UpdateTime int64 `json:"update_time"` +} + +type Llm struct { + ModelName string `json:"model_name,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` +} + +type Prompt struct { + SimilarityThreshold float64 `json:"similarity_threshold,omitempty"` + KeywordsSimilarityWeight float64 `json:"keywords_similarity_weight,omitempty"` + Opener string `json:"opener,omitempty"` + Prompt string `json:"prompt,omitempty"` + RerankModel string `json:"rerank_model,omitempty"` + TopN int `json:"top_n,omitempty"` + Variables []Variable `json:"variables,omitempty"` + EmptyResponse string `json:"empty_response,omitempty"` +} + +type Variable struct { + Key string `json:"key"` + Optional bool `json:"optional"` +} + +// ListChatsReq 列出聊天助手请求 +type ListChatsReq struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + OrderBy string `json:"orderby,omitempty"` + Desc bool `json:"desc,omitempty"` + Name string `json:"name,omitempty"` + Id string `json:"id,omitempty"` +} + +// ListChatsRes 列出聊天助手响应 +// 注意:API 不返回 total 字段,仅返回 data 数组 +type ListChatsRes struct { + Code int `json:"code"` // 状态码,0 表示成功 + Data []*Chat `json:"data"` // 聊天助手列表 +} + +// DeleteChatsReq 删除聊天助手请求 +type DeleteChatsReq struct { + Ids []string `json:"ids"` +} + +// CreateChat 创建聊天助手 +func (c *Client) CreateChat(ctx context.Context, req *CreateChatReq) (*Chat, error) { + var res struct { + Code int `json:"code"` + Data *Chat `json:"data"` + Msg string `json:"message"` + } + if err := c.request(ctx, "POST", "/api/v1/chats", req, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("create chat failed: %s", res.Msg) + } + // 检查响应数据是否为空:防止RAGFlow API返回 {"code":0, "data":null} + // 如果不检查直接返回,调用方会收到 (nil, nil),导致空指针异常 + if res.Data == nil { + return nil, gerror.Newf("create chat returned null data: %s", res.Msg) + } + return res.Data, nil +} + +// ListChats 列出聊天助手 +func (c *Client) ListChats(ctx context.Context, req *ListChatsReq) (*ListChatsRes, error) { + path := "/api/v1/chats" + params := map[string]interface{}{} + if req.Page > 0 { + params["page"] = req.Page + } + if req.PageSize > 0 { + params["page_size"] = req.PageSize + } + if req.OrderBy != "" { + params["orderby"] = req.OrderBy + } + if req.Desc { + params["desc"] = "true" + } else { + params["desc"] = "false" + } + if req.Name != "" { + params["name"] = req.Name + } + if req.Id != "" { + params["id"] = req.Id + } + + query := buildQueryString(params) + if query != "" { + path += "?" + query + } + + var res ListChatsRes + if err := c.request(ctx, "GET", path, nil, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("list chats failed: code=%d", res.Code) + } + return &res, nil +} + +// DeleteChats 删除聊天助手 +func (c *Client) DeleteChats(ctx context.Context, ids []string) (err error) { + req := DeleteChatsReq{Ids: ids} + var res CommonResponse + if err = c.request(ctx, "DELETE", "/api/v1/chats", req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("delete chats failed: %s", res.Message) + } + return +} + +// UpdateChat 更新聊天助手 +func (c *Client) UpdateChat(ctx context.Context, id string, req *UpdateChatReq) (err error) { + var res CommonResponse + path := "/api/v1/chats/" + id + if err = c.request(ctx, "PUT", path, req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("update chat failed: %s", res.Message) + } + return +} diff --git a/ragflow/chunk.go b/ragflow/chunk.go new file mode 100644 index 0000000..030d21a --- /dev/null +++ b/ragflow/chunk.go @@ -0,0 +1,180 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" +) + +// 数据集内知识块管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#数据集内知识块管理 + +// Chunk 知识块结构体 +type Chunk struct { + Id string `json:"id"` + Content string `json:"content"` + DocumentId string `json:"document_id"` + DatasetId string `json:"dataset_id"` + CreateTime string `json:"create_time"` + CreateTimestamp float64 `json:"create_timestamp"` + ImportantKeywords []string `json:"important_keywords"` + Questions []string `json:"questions"` + Available bool `json:"available"` + ImageId string `json:"image_id"` + Positions []string `json:"positions"` +} + +// AddChunkReq 添加知识块请求 +type AddChunkReq struct { + Content string `json:"content"` + ImportantKeywords []string `json:"important_keywords,omitempty"` + Questions []string `json:"questions,omitempty"` +} + +// ListChunksReq 列出知识块请求 +type ListChunksReq struct { + Keywords string `json:"keywords,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Id string `json:"id,omitempty"` +} + +// ListChunksRes 列出知识块响应 +// 注意:响应结构包含 chunks(知识块列表)、doc(关联文档信息)和 total(总数) +type ListChunksRes struct { + Code int `json:"code"` // 状态码,0 表示成功 + Data struct { + Chunks []*Chunk `json:"chunks"` // 知识块列表 + Doc interface{} `json:"doc"` // 关联文档信息(完整的 Document 对象) + Total int `json:"total"` // 知识块总数 + } `json:"data"` +} + +// DeleteChunksReq 删除知识块请求 +type DeleteChunksReq struct { + ChunkIds []string `json:"chunk_ids,omitempty"` // 如果为空,删除所有 +} + +// UpdateChunkReq 更新知识块请求 +type UpdateChunkReq struct { + Content string `json:"content,omitempty"` + ImportantKeywords []string `json:"important_keywords,omitempty"` + Available *bool `json:"available,omitempty"` +} + +// RetrieveChunksReq 检索知识块请求 +type RetrieveChunksReq struct { + Question string `json:"question"` + DatasetIds []string `json:"dataset_ids,omitempty"` + DocumentIds []string `json:"document_ids,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + SimilarityThreshold float64 `json:"similarity_threshold,omitempty"` + VectorSimilarityWeight float64 `json:"vector_similarity_weight,omitempty"` + TopK int `json:"top_k,omitempty"` + RerankId string `json:"rerank_id,omitempty"` + Keyword bool `json:"keyword,omitempty"` + Highlight bool `json:"highlight,omitempty"` + CrossLanguages []string `json:"cross_languages,omitempty"` + MetadataCondition map[string]interface{} `json:"metadata_condition,omitempty"` +} + +// RetrieveChunksRes 检索知识块响应 (结构比较复杂,暂时简化,根据实际返回调整) +// 官方文档未给出详细响应结构,假设返回 chunks 列表 +type RetrieveChunksRes struct { + Code int `json:"code"` + Data struct { + Chunks []interface{} `json:"chunks"` // 检索结果可能包含额外信息 + Total int `json:"total"` + } `json:"data"` +} + +// AddChunk 添加知识块 +func (c *Client) AddChunk(ctx context.Context, datasetId, documentId string, req *AddChunkReq) (*Chunk, error) { + path := "/api/v1/datasets/" + datasetId + "/documents/" + documentId + "/chunks" + var res struct { + Code int `json:"code"` + Data struct { + Chunk *Chunk `json:"chunk"` + } `json:"data"` + Msg string `json:"message"` + } + if err := c.request(ctx, "POST", path, req, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("add chunk failed: %s", res.Msg) + } + return res.Data.Chunk, nil +} + +// ListChunks 列出知识块 +func (c *Client) ListChunks(ctx context.Context, datasetId, documentId string, req *ListChunksReq) (*ListChunksRes, error) { + path := "/api/v1/datasets/" + datasetId + "/documents/" + documentId + "/chunks" + params := map[string]interface{}{} + if req.Keywords != "" { + params["keywords"] = req.Keywords + } + if req.Page > 0 { + params["page"] = req.Page + } + if req.PageSize > 0 { + params["page_size"] = req.PageSize + } + if req.Id != "" { + params["id"] = req.Id + } + + query := buildQueryString(params) + if query != "" { + path += "?" + query + } + + var res ListChunksRes + if err := c.request(ctx, "GET", path, nil, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("list chunks failed: code=%d", res.Code) + } + return &res, nil +} + +// DeleteChunks 删除知识块 +func (c *Client) DeleteChunks(ctx context.Context, datasetId, documentId string, chunkIds []string) (err error) { + req := DeleteChunksReq{ChunkIds: chunkIds} + var res CommonResponse + path := "/api/v1/datasets/" + datasetId + "/documents/" + documentId + "/chunks" + if err = c.request(ctx, "DELETE", path, req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("delete chunks failed: %s", res.Message) + } + return +} + +// UpdateChunk 更新知识块 +func (c *Client) UpdateChunk(ctx context.Context, datasetId, documentId, chunkId string, req *UpdateChunkReq) (err error) { + var res CommonResponse + path := "/api/v1/datasets/" + datasetId + "/documents/" + documentId + "/chunks/" + chunkId + if err = c.request(ctx, "PUT", path, req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("update chunk failed: %s", res.Message) + } + return +} + +// RetrieveChunks 检索知识块 +func (c *Client) RetrieveChunks(ctx context.Context, req *RetrieveChunksReq) (*RetrieveChunksRes, error) { + var res RetrieveChunksRes + if err := c.request(ctx, "POST", "/api/v1/retrieval", req, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("retrieve chunks failed: code=%d", res.Code) + } + return &res, nil +} diff --git a/ragflow/client.go b/ragflow/client.go new file mode 100644 index 0000000..f88b0ba --- /dev/null +++ b/ragflow/client.go @@ -0,0 +1,195 @@ +package ragflow + +import ( + "context" + "encoding/json" + "net/url" + "strings" + "sync" + "sync/atomic" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" +) + +var ( + // globalClient 全局 RAGFlow 客户端(单例,延迟初始化) + globalClient *Client + clientOnce sync.Once +) + +// initClient 延迟初始化客户端 +func initClient() { + clientOnce.Do(func() { + ctx := context.Background() + + // 读取配置 + endpoints, apiKey := loadConfig(ctx) + + // 如果配置不完整,跳过初始化 + if len(endpoints) == 0 || apiKey == "" { + g.Log().Warning(ctx, "⚠️ RAGFlow 配置未找到,请在 config.yml 中添加 ragflow.base_url 或在 Consul 中配置 ragflow.endpoints") + return + } + + globalClient = &Client{ + Endpoints: endpoints, + APIKey: apiKey, + } + + if len(endpoints) == 1 { + g.Log().Infof(ctx, "✅ RAGFlow 客户端初始化成功: endpoint=%s", endpoints[0]) + } else { + g.Log().Infof(ctx, "✅ RAGFlow 客户端初始化成功: endpoints=%v (负载均衡)", endpoints) + } + }) +} + +// loadConfig 从配置加载 RAGFlow 配置(支持实例级配置) +// 优先级: +// 1. Consul实例级配置 ragflow.endpoints (数组) +// 2. Consul全局配置 ragflow.endpoints (数组) +// 3. config.yml的 ragflow.base_url (单个URL,向后兼容) +func loadConfig(ctx context.Context) (endpoints []string, apiKey string) { + // 尝试从Consul读取endpoints(支持实例级配置) + // 注意:这里不能直接导入customerService/service包,会造成循环依赖 + // 所以只能从config.yml读取,Consul配置需要在customerservice层面调用时传入 + + // 读取API Key + apiKey = g.Cfg().MustGet(ctx, "ragflow.api_key", "").String() + + // 尝试读取endpoints数组(从config.yml或Consul同步的配置) + endpointsConfig := g.Cfg().MustGet(ctx, "ragflow.endpoints") + if !endpointsConfig.IsEmpty() { + endpoints = endpointsConfig.Strings() + // 去除尾部斜杠 + for i := range endpoints { + endpoints[i] = strings.TrimSuffix(endpoints[i], "/") + } + return + } + + // Fallback到单个base_url(向后兼容) + baseURL := g.Cfg().MustGet(ctx, "ragflow.base_url", "").String() + if baseURL != "" { + endpoints = []string{strings.TrimSuffix(baseURL, "/")} + } + + return +} + +// GetGlobalClient 获取全局客户端(延迟初始化) +func GetGlobalClient() *Client { + initClient() + return globalClient +} + +// Client RAGFlow API 客户端(支持负载均衡) +type Client struct { + Endpoints []string // RAGFlow实例列表 + APIKey string // API密钥 + currentIndex atomic.Uint64 // 当前轮询索引(原子操作) +} + +// getNextEndpoint 获取下一个endpoint(轮询算法) +func (c *Client) getNextEndpoint() string { + if len(c.Endpoints) == 0 { + return "" + } + if len(c.Endpoints) == 1 { + return c.Endpoints[0] + } + // 原子递增并取模,实现轮询 + idx := c.currentIndex.Add(1) % uint64(len(c.Endpoints)) + return c.Endpoints[idx] +} + +// CommonResponse 通用响应结构 +type CommonResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// IsSuccess 检查响应是否成功 +func (r *CommonResponse) IsSuccess() bool { + return r.Code == 0 +} + +// request 发送 HTTP 请求 +// +// 为什么不使用 common/http 包: +// common/http包统一处理内部API响应格式(ghttp.DefaultHandlerResponse), +// RAGFlow API返回格式为{code,data,message}一层结构,与内部API不同。 +// 因此直接使用 g.Client() 调用第三方API,在此处理RAGFlow特有的响应格式。 +func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) (err error) { + endpoint := c.getNextEndpoint() + if endpoint == "" { + return gerror.New("RAGFlow endpoints not configured") + } + + fullURL := endpoint + path + + // 创建HTTP客户端并设置RAGFlow专用请求头 + client := g.Client() + client.SetHeader("Authorization", "Bearer "+c.APIKey) + client.SetHeader("Content-Type", "application/json") + + // 发送HTTP请求(避免data展开导致的双重包装) + var response *gclient.Response + switch method { + case "GET": + if body != nil { + response, err = client.Get(ctx, fullURL, body) + } else { + response, err = client.Get(ctx, fullURL) + } + case "POST": + if body != nil { + response, err = client.Post(ctx, fullURL, body) + } else { + response, err = client.Post(ctx, fullURL) + } + case "PUT": + if body != nil { + response, err = client.Put(ctx, fullURL, body) + } else { + response, err = client.Put(ctx, fullURL) + } + case "DELETE": + if body != nil { + response, err = client.Delete(ctx, fullURL, body) + } else { + response, err = client.Delete(ctx, fullURL) + } + default: + return gerror.Newf("unsupported method: %s", method) + } + + if err != nil { + return + } + defer response.Close() + + // RAGFlow API响应格式:{code,data,message}一层结构,直接解析 + responseBody := response.ReadAll() + if err = json.Unmarshal(responseBody, result); err != nil { + return gerror.Newf("RAGFlow响应解析失败: %v, 原始响应: %s", err, string(responseBody)) + } + + return +} + +// buildQueryString 构建查询字符串 +func buildQueryString(params map[string]interface{}) string { + if len(params) == 0 { + return "" + } + + parts := make([]string, 0, len(params)) + for k, v := range params { + parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(g.NewVar(v).String())) + } + return strings.Join(parts, "&") +} diff --git a/ragflow/dataset.go b/ragflow/dataset.go new file mode 100644 index 0000000..c5ac88b --- /dev/null +++ b/ragflow/dataset.go @@ -0,0 +1,190 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// 数据集管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#数据集管理 + +// Dataset 数据集结构体 +type Dataset struct { + Id string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + TenantId string `json:"tenant_id"` + Description string `json:"description"` + Language string `json:"language"` + EmbeddingModel string `json:"embedding_model"` + Permission string `json:"permission"` + DocumentCount int `json:"document_count"` + ChunkCount int `json:"chunk_count"` + ParseStatus string `json:"parse_status"` + CreatedBy string `json:"created_by"` + CreateTime int64 `json:"create_time"` + UpdateDate string `json:"update_date"` + UpdateTime int64 `json:"update_time"` + Status string `json:"status"` + ChunkMethod string `json:"chunk_method"` + ParserConfig map[string]interface{} `json:"parser_config"` + VectorSimilarityWeight float64 `json:"vector_similarity_weight"` + SimilarityThreshold float64 `json:"similarity_threshold"` + TokenNum int `json:"token_num"` +} + +// CreateDatasetReq 创建数据集请求 +type CreateDatasetReq struct { + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + Description string `json:"description,omitempty"` + EmbeddingModel string `json:"embedding_model,omitempty"` + Permission string `json:"permission,omitempty"` + ChunkMethod string `json:"chunk_method,omitempty"` + ParserConfig map[string]interface{} `json:"parser_config,omitempty"` +} + +// UpdateDatasetReq 更新数据集请求 +type UpdateDatasetReq struct { + Name string `json:"name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Description string `json:"description,omitempty"` + EmbeddingModel string `json:"embedding_model,omitempty"` + Permission string `json:"permission,omitempty"` + ChunkMethod string `json:"chunk_method,omitempty"` + PageRank int `json:"pagerank,omitempty"` + ParserConfig map[string]interface{} `json:"parser_config,omitempty"` +} + +// ListDatasetsReq 列出数据集请求 +type ListDatasetsReq struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + OrderBy string `json:"orderby,omitempty"` + Desc bool `json:"desc,omitempty"` + Name string `json:"name,omitempty"` + Id string `json:"id,omitempty"` +} + +// ListDatasetsRes 列出数据集响应 +// 注意:与 Agent/Chat 等接口不同,Dataset API 会返回 total 字段 +type ListDatasetsRes struct { + Code int `json:"code"` // 状态码,0 表示成功 + Data []*Dataset `json:"data"` // 数据集列表 + Total int `json:"total"` // 总数据集数 +} + +// DeleteDatasetsReq 删除数据集请求 +type DeleteDatasetsReq struct { + Ids []string `json:"ids"` +} + +// CreateDataset 创建数据集 +func (c *Client) CreateDataset(ctx context.Context, req *CreateDatasetReq) (*Dataset, error) { + g.Log().Infof(ctx, "CreateDataset请求: name=%s, description=%s, embedding_model=%s", req.Name, req.Description, req.EmbeddingModel) + + var res struct { + Code int `json:"code"` + Data *Dataset `json:"data"` + Msg string `json:"message"` + } + if err := c.request(ctx, "POST", "/api/v1/datasets", req, &res); err != nil { + g.Log().Errorf(ctx, "CreateDataset请求失败: %v", err) + return nil, err + } + + g.Log().Infof(ctx, "CreateDataset响应: code=%d, msg=%s, data_is_nil=%v", res.Code, res.Msg, res.Data == nil) + + // code=101表示dataset名称已存在(正常业务场景,不是错误) + // 调用方应该通过ListDatasets查找已有dataset并复用 + if res.Code == 101 { + return nil, gerror.Newf("Dataset名称已存在: %s", res.Msg) + } + + // 其他非0的code表示真正的错误 + if res.Code != 0 { + return nil, gerror.Newf("创建知识库失败(code=%d): %s", res.Code, res.Msg) + } + + // code=0但data=null,表示创建异常(可能是RAGFlow配置问题,如embedding模型不可用、权限不足等) + // 这不是正常状态,应该返回错误而不是(nil, nil) + if res.Data == nil { + return nil, gerror.Newf("创建知识库返回空数据(code=0,data=null),可能是RAGFlow配置问题: %s", res.Msg) + } + + g.Log().Infof(ctx, "CreateDataset成功: id=%s, name=%s", res.Data.Id, res.Data.Name) + return res.Data, nil +} + +// ListDatasets 列出数据集 +func (c *Client) ListDatasets(ctx context.Context, req *ListDatasetsReq) (*ListDatasetsRes, error) { + // 构建查询参数 + path := "/api/v1/datasets" + params := map[string]interface{}{} + if req.Page > 0 { + params["page"] = req.Page + } + if req.PageSize > 0 { + params["page_size"] = req.PageSize + } + if req.OrderBy != "" { + params["orderby"] = req.OrderBy + } + // desc 默认为 true,如果显式设置为 false 才传递,或者根据 API 行为调整 + // 这里简单处理,如果设置了就传 + if req.Desc { + params["desc"] = "true" + } else { + params["desc"] = "false" + } + if req.Name != "" { + params["name"] = req.Name + } + if req.Id != "" { + params["id"] = req.Id + } + + // 拼接 query string + query := buildQueryString(params) + if query != "" { + path += "?" + query + } + + var res ListDatasetsRes + if err := c.request(ctx, "GET", path, nil, &res); err != nil { + return nil, err + } + + if res.Code != 0 { + return nil, gerror.Newf("list datasets failed: code=%d", res.Code) + } + return &res, nil +} + +// DeleteDataset 删除数据集 +func (c *Client) DeleteDataset(ctx context.Context, ids []string) (err error) { + req := DeleteDatasetsReq{Ids: ids} + var res CommonResponse + if err = c.request(ctx, "DELETE", "/api/v1/datasets", req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("delete dataset failed: %s", res.Message) + } + return +} + +// UpdateDataset 更新数据集 +func (c *Client) UpdateDataset(ctx context.Context, id string, req *UpdateDatasetReq) (err error) { + var res CommonResponse + path := "/api/v1/datasets/" + id + if err = c.request(ctx, "PUT", path, req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("update dataset failed: %s", res.Message) + } + return +} diff --git a/ragflow/document.go b/ragflow/document.go new file mode 100644 index 0000000..b2c9002 --- /dev/null +++ b/ragflow/document.go @@ -0,0 +1,274 @@ +// Package ragflow - RAGFlow文档管理 +// 功能:RAGFlow知识库文档的上传、列表、删除操作 +package ragflow + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "strings" + + commonHttp "gitea.com/red-future/common/http" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// 数据集内文件管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#数据集内文件管理 + +// ... (rest of the code remains the same) +type Document struct { + Id string `json:"id"` + DatasetId string `json:"dataset_id"` + Name string `json:"name"` + Size int64 `json:"size"` + Location string `json:"location"` + CreatedBy string `json:"created_by"` + CreateTime int64 `json:"create_time"` + Thumbnail string `json:"thumbnail"` + Type string `json:"type"` + RunStatus string `json:"run_status"` // 对应 API 返回的 "run" 字段,可能需要确认 + Status string `json:"status"` + ChunkMethod string `json:"chunk_method"` + ParserConfig map[string]interface{} `json:"parser_config"` + TokenNum int `json:"token_num"` + ChunkCount int `json:"chunk_count"` + ProcessBegin int64 `json:"process_begin"` + ProcessDu int64 `json:"process_du"` + Progress float64 `json:"progress"` + ProgressMsg string `json:"progress_msg"` +} + +// UploadDocumentReq 上传文档请求 +// 注意:上传文件通常需要 multipart/form-data,这里仅定义结构,实际逻辑在方法中处理 +type UploadDocumentReq struct { + FilePaths []string // 本地文件路径列表 +} + +// UploadDocumentRes 上传文档响应 +type UploadDocumentRes struct { + Id string `json:"id"` // 文档ID +} + +// ListDocumentsReq 列出文档请求 +type ListDocumentsReq struct { + Page int `json:"page,omitempty"` // 页码,默认 1 + PageSize int `json:"page_size,omitempty"` // 每页数量,默认 30 + OrderBy string `json:"orderby,omitempty"` // 排序字段:create_time(默认)或 update_time + Desc bool `json:"desc,omitempty"` // 是否降序,默认 true + Keywords string `json:"keywords,omitempty"` // 关键词过滤(匹配文档标题) + Id string `json:"id,omitempty"` // 文档 ID 过滤 + Name string `json:"name,omitempty"` // 文档名称过滤 + CreateTimeFrom int64 `json:"create_time_from,omitempty"` // 创建时间起始(Unix 时间戳),0 表示无限制 + CreateTimeTo int64 `json:"create_time_to,omitempty"` // 创建时间截止(Unix 时间戳),0 表示无限制 + Suffix []string `json:"suffix,omitempty"` // 文件后缀过滤,如 ["pdf", "txt", "docx"] + Run []string `json:"run,omitempty"` // 处理状态过滤,支持 ["UNSTART", "RUNNING", "CANCEL", "DONE", "FAIL"] 或数字格式 ["0", "1", "2", "3", "4"] +} + +// ListDocumentsRes 列出文档响应 +// 注意:响应结构与其他 List 接口不同,data 是一个对象而非数组 +type ListDocumentsRes struct { + Code int `json:"code"` // 状态码,0 表示成功 + Data struct { + Docs []*Document `json:"docs"` // 文档列表 + TotalDatasets int `json:"total_datasets"` // 总文档数 + } `json:"data"` +} + +// DeleteDocumentsReq 删除文档请求 +type DeleteDocumentsReq struct { + Ids []string `json:"ids"` +} + +// ListDocuments 列出文档 +func (c *Client) ListDocuments(ctx context.Context, datasetId string, req *ListDocumentsReq) (*ListDocumentsRes, error) { + path := "/api/v1/datasets/" + datasetId + "/documents" + params := map[string]interface{}{} + if req.Page > 0 { + params["page"] = req.Page + } + if req.PageSize > 0 { + params["page_size"] = req.PageSize + } + if req.OrderBy != "" { + params["orderby"] = req.OrderBy + } + if req.Desc { + params["desc"] = "true" + } else { + params["desc"] = "false" + } + if req.Keywords != "" { + params["keywords"] = req.Keywords + } + if req.Id != "" { + params["id"] = req.Id + } + if req.Name != "" { + params["name"] = req.Name + } + if req.CreateTimeFrom > 0 { + params["create_time_from"] = req.CreateTimeFrom + } + if req.CreateTimeTo > 0 { + params["create_time_to"] = req.CreateTimeTo + } + + // 构造查询字符串 + query := buildQueryString(params) + var queryParts []string + if query != "" { + queryParts = append(queryParts, query) + } + + // 处理数组参数:suffix(文件后缀过滤) + // API 要求多个值时重复参数名,如:suffix=pdf&suffix=txt + for _, suffix := range req.Suffix { + queryParts = append(queryParts, "suffix="+suffix) + } + + // 处理数组参数:run(处理状态过滤) + // 支持数字格式("0"-"4")或文本格式("UNSTART", "RUNNING", "CANCEL", "DONE", "FAIL") + for _, run := range req.Run { + queryParts = append(queryParts, "run="+run) + } + + // 构造最终请求路径 + if len(queryParts) > 0 { + path += "?" + strings.Join(queryParts, "&") + } + + // 发送请求并处理响应 + var res ListDocumentsRes + if err := c.request(ctx, "GET", path, nil, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("list documents failed: code=%d", res.Code) + } + return &res, nil +} + +// UploadDocumentFromText 上传文本内容作为文档 +func (c *Client) UploadDocumentFromText(ctx context.Context, datasetId, content, filename string) (documentId string, err error) { + if datasetId == "" { + return "", gerror.New("datasetId不能为空") + } + if content == "" { + return "", gerror.New("文档内容不能为空") + } + if filename == "" { + filename = "document.txt" + } + + // 构造URL(使用负载均衡) + endpoint := c.getNextEndpoint() + if endpoint == "" { + return "", gerror.New("RAGFlow endpoints not configured") + } + url := endpoint + "/api/v1/datasets/" + datasetId + "/documents" + + // 创建multipart writer + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // 添加文件字段 + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return "", gerror.Wrap(err, "创建form file失败") + } + + // 写入内容 + if _, err = part.Write([]byte(content)); err != nil { + return "", gerror.Wrap(err, "写入文件内容失败") + } + + // 关闭multipart writer + if err = writer.Close(); err != nil { + return "", gerror.Wrap(err, "关闭multipart writer失败") + } + + // 发送请求 + client := commonHttp.Httpclient.Clone() + client.SetHeader("Authorization", "Bearer "+c.APIKey) + client.SetHeader("Content-Type", writer.FormDataContentType()) + + resp, err := client.Post(ctx, url, body.Bytes()) + if err != nil { + return "", gerror.Wrap(err, "上传文档请求失败") + } + defer resp.Close() + + // 解析响应 + var response struct { + Code int `json:"code"` + Message string `json:"message"` + Data []UploadDocumentRes `json:"data"` // RAGFlow返回数组 + } + + respBody := resp.ReadAll() + + if err := json.Unmarshal(respBody, &response); err != nil { + g.Log().Errorf(ctx, "解析RAGFlow响应失败: %v, 原始响应: %s", err, string(respBody)) + return "", gerror.Newf("json Decode failed: %v", err) + } + + // 先检查code,再检查data + if response.Code != 0 { + g.Log().Errorf(ctx, "RAGFlow返回错误: code=%d, message=%s", response.Code, response.Message) + return "", gerror.Newf("上传文档失败 (code=%d): %s", response.Code, response.Message) + } + + if len(response.Data) == 0 { + g.Log().Errorf(ctx, "RAGFlow返回data为空, 完整响应: %s", string(respBody)) + return "", gerror.New("上传文档返回data为空") + } + + return response.Data[0].Id, nil +} + +// UploadDocument 上传文档(保留兼容) +func (c *Client) UploadDocument(ctx context.Context, datasetId string, filePaths []string) (err error) { + return gerror.New("upload document from file not implemented yet, use UploadDocumentFromText instead") +} + +// ParseDocumentsReq 解析文档请求 +type ParseDocumentsReq struct { + DocumentIds []string `json:"document_ids"` // 要解析的文档ID列表 +} + +// ParseDocuments 解析文档(上传后必须调用此接口才会开始解析) +func (c *Client) ParseDocuments(ctx context.Context, datasetId string, documentIds []string) error { + if datasetId == "" { + return gerror.New("datasetId不能为空") + } + if len(documentIds) == 0 { + return gerror.New("documentIds不能为空") + } + + req := ParseDocumentsReq{DocumentIds: documentIds} + var res CommonResponse + path := "/api/v1/datasets/" + datasetId + "/chunks" + if err := c.request(ctx, "POST", path, req, &res); err != nil { + return err + } + if !res.IsSuccess() { + return gerror.Newf("解析文档失败: %s", res.Message) + } + return nil +} + +// DeleteDocument 删除文档 +func (c *Client) DeleteDocument(ctx context.Context, datasetId string, ids []string) (err error) { + req := DeleteDocumentsReq{Ids: ids} + var res CommonResponse + path := "/api/v1/datasets/" + datasetId + "/documents" + if err = c.request(ctx, "DELETE", path, req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("delete document failed: %s", res.Message) + } + return +} diff --git a/ragflow/openai.go b/ragflow/openai.go new file mode 100644 index 0000000..4218592 --- /dev/null +++ b/ragflow/openai.go @@ -0,0 +1,117 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" +) + +// OpenAICompatibleAPI 与 OpenAI 兼容的 API +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#与-openai-兼容的-api + +// ChatCompletionMessage OpenAI 格式的消息 +type ChatCompletionMessage struct { + Role string `json:"role"` // "user", "assistant", "system" + Content string `json:"content"` +} + +// ChatCompletionRequest OpenAI 格式的聊天补全请求 +type ChatCompletionRequest struct { + Model string `json:"model"` // 模型名称(服务器会自动解析,可设置为任意值) + Messages []ChatCompletionMessage `json:"messages"` // 消息列表,必须至少包含一条 user 消息 + Stream bool `json:"stream,omitempty"` // 是否流式返回,默认 false +} + +// ChatCompletionResponse OpenAI 格式的聊天补全响应(非流式) +type ChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Message ChatCompletionMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +// ChatCompletionChunk 流式响应块 +type ChatCompletionChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []struct { + Index int `json:"index"` + Delta struct { + Content string `json:"content"` + Role string `json:"role"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage *struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` +} + +// CreateChatCompletion 创建聊天补全(与聊天助手) +// POST /api/v1/chats_openai/{chat_id}/chat/completions +func (c *Client) CreateChatCompletion(ctx context.Context, chatID string, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { + path := "/api/v1/chats_openai/" + chatID + "/chat/completions" + + var resp ChatCompletionResponse + if err := c.request(ctx, "POST", path, req, &resp); err != nil { + return nil, gerror.Newf("create chat completion failed: %v", err) + } + + return &resp, nil +} + +// CreateAgentCompletion 创建 Agent 补全 +// POST /api/v1/agents_openai/{agent_id}/chat/completions +func (c *Client) CreateAgentCompletion(ctx context.Context, agentID string, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { + path := "/api/v1/agents_openai/" + agentID + "/chat/completions" + + var resp ChatCompletionResponse + if err := c.request(ctx, "POST", path, req, &resp); err != nil { + return nil, gerror.Newf("create agent completion failed: %v", err) + } + + return &resp, nil +} + +// CreateChatCompletionStream 创建流式聊天补全(与聊天助手) +// 注意:流式响应需要特殊处理,这里返回一个可用于读取流的接口 +func (c *Client) CreateChatCompletionStream(ctx context.Context, chatID string, req *ChatCompletionRequest) (*StreamReader, error) { + req.Stream = true + // TODO: 实现流式读取逻辑 + return nil, gerror.New("stream mode not implemented yet") +} + +// StreamReader 流式响应读取器 +type StreamReader struct { + _ *gjson.Json // TODO: 实现流式读取时使用 + close func() error +} + +// ReadChunk 读取下一个响应块 +// TODO: 实现流式读取逻辑 +func (sr *StreamReader) ReadChunk() (*ChatCompletionChunk, error) { + return nil, gerror.New("stream mode not implemented yet") +} + +// Close 关闭流 +func (sr *StreamReader) Close() (err error) { + if sr.close != nil { + return sr.close() + } + return +} diff --git a/ragflow/session.go b/ragflow/session.go new file mode 100644 index 0000000..966d0e8 --- /dev/null +++ b/ragflow/session.go @@ -0,0 +1,178 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// 会话管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#会话管理 + +// Session 会话结构体 +type Session struct { + Id string `json:"id"` + Name string `json:"name"` + ChatId string `json:"chat_id"` // 响应中是 "chat" 或 "chat_id",根据文档示例调整 + Messages []Message `json:"messages"` + CreateDate string `json:"create_date"` + CreateTime int64 `json:"create_time"` + UpdateDate string `json:"update_date"` + UpdateTime int64 `json:"update_time"` +} + +type Message struct { + Content string `json:"content"` + Role string `json:"role"` +} + +// CreateSessionReq 创建会话请求 +type CreateSessionReq struct { + Name string `json:"name"` + UserId string `json:"user_id,omitempty"` +} + +// ListSessionsReq 列出会话请求 +type ListSessionsReq struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + OrderBy string `json:"orderby,omitempty"` + Desc bool `json:"desc,omitempty"` + Name string `json:"name,omitempty"` + Id string `json:"id,omitempty"` + UserId string `json:"user_id,omitempty"` +} + +// ListSessionsRes 列出会话响应 +// 注意:API 不返回 total 字段,仅返回 data 数组 +type ListSessionsRes struct { + Code int `json:"code"` // 状态码,0 表示成功 + Data []*Session `json:"data"` // 会话列表 +} + +// DeleteSessionsReq 删除会话请求 +type DeleteSessionsReq struct { + Ids []string `json:"ids"` +} + +// ChatCompletionReq 对话请求 +type ChatCompletionReq struct { + Question string `json:"question"` + Stream bool `json:"stream"` + SessionId string `json:"session_id,omitempty"` + UserId string `json:"user_id,omitempty"` +} + +// ChatCompletionRes 对话响应 (非流式) +type ChatCompletionRes struct { + Code int `json:"code"` + Message string `json:"message"` // 错误信息 + Data struct { + Answer string `json:"answer"` + Reference interface{} `json:"reference"` + AudioBinary interface{} `json:"audio_binary"` + Id interface{} `json:"id"` + SessionId string `json:"session_id"` + } `json:"data"` +} + +// CreateSession 创建会话 +func (c *Client) CreateSession(ctx context.Context, chatId string, req *CreateSessionReq) (*Session, error) { + path := "/api/v1/chats/" + chatId + "/sessions" + var res struct { + Code int `json:"code"` + Data *Session `json:"data"` + Msg string `json:"message"` + } + if err := c.request(ctx, "POST", path, req, &res); err != nil { + g.Log().Errorf(ctx, "❌ CreateSession请求失败: chatId=%s, req=%+v, error=%v", chatId, req, err) + return nil, err + } + if res.Code != 0 { + g.Log().Errorf(ctx, "❌ CreateSession返回失败: chatId=%s, req=%+v, code=%d, msg=%s", chatId, req, res.Code, res.Msg) + return nil, gerror.Newf("create session failed: %s", res.Msg) + } + // 检查响应数据是否为空:防止RAGFlow API返回 {"code":0, "data":null} + // 如果不检查直接返回,调用方会收到 (nil, nil),导致空指针异常 + if res.Data == nil { + return nil, gerror.Newf("create session returned null data: %s", res.Msg) + } + return res.Data, nil +} + +// ListSessions 列出会话 +func (c *Client) ListSessions(ctx context.Context, chatId string, req *ListSessionsReq) (*ListSessionsRes, error) { + path := "/api/v1/chats/" + chatId + "/sessions" + params := map[string]interface{}{} + if req.Page > 0 { + params["page"] = req.Page + } + if req.PageSize > 0 { + params["page_size"] = req.PageSize + } + if req.OrderBy != "" { + params["orderby"] = req.OrderBy + } + if req.Desc { + params["desc"] = "true" + } else { + params["desc"] = "false" + } + if req.Name != "" { + params["name"] = req.Name + } + if req.Id != "" { + params["id"] = req.Id + } + if req.UserId != "" { + params["user_id"] = req.UserId + } + + query := buildQueryString(params) + if query != "" { + path += "?" + query + } + + var res ListSessionsRes + if err := c.request(ctx, "GET", path, nil, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("list sessions failed: code=%d", res.Code) + } + return &res, nil +} + +// DeleteSessions 删除会话 +func (c *Client) DeleteSessions(ctx context.Context, chatId string, ids []string) (err error) { + req := DeleteSessionsReq{Ids: ids} + var res CommonResponse + path := "/api/v1/chats/" + chatId + "/sessions" + if err = c.request(ctx, "DELETE", path, req, &res); err != nil { + return + } + if !res.IsSuccess() { + return gerror.Newf("delete sessions failed: %s", res.Message) + } + return +} + +// ChatCompletion 对话 (目前仅支持非流式) +func (c *Client) ChatCompletion(ctx context.Context, chatId string, req *ChatCompletionReq) (*ChatCompletionRes, error) { + path := "/api/v1/chats/" + chatId + "/completions" + var res ChatCompletionRes + + // 如果需要流式支持,需要使用 gclient 的流式处理能力,这里暂只实现非流式 + if req.Stream { + return nil, gerror.New("stream mode not supported yet") + } + + if err := c.request(ctx, "POST", path, req, &res); err != nil { + return nil, err + } + if res.Code != 0 { + return nil, gerror.Newf("chat completion failed: code=%d, message=%s", res.Code, res.Message) + } + return &res, nil +} diff --git a/ragflow/system.go b/ragflow/system.go new file mode 100644 index 0000000..1a7da73 --- /dev/null +++ b/ragflow/system.go @@ -0,0 +1,39 @@ +package ragflow + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" +) + +// System 系统管理 +// 参考: https://ragflow.com.cn/docs/dev/http_api_reference#系统 + +// HealthStatus 健康状态 +type HealthStatus struct { + DB string `json:"db"` // "ok" 或 "nok" + Redis string `json:"redis"` // "ok" 或 "nok" + DocEngine string `json:"doc_engine"` // "ok" 或 "nok" + Storage string `json:"storage"` // "ok" 或 "nok" + Status string `json:"status"` // 整体状态: "ok" 或 "nok" + Meta map[string]interface{} `json:"_meta,omitempty"` // 详细错误信息 +} + +// CheckHealth 检查系统健康状况 +// GET /v1/system/healthz +func (c *Client) CheckHealth(ctx context.Context) (*HealthStatus, error) { + var status HealthStatus + if err := c.request(ctx, "GET", "/v1/system/healthz", nil, &status); err != nil { + return nil, gerror.Newf("check health failed: %v", err) + } + return &status, nil +} + +// IsHealthy 检查系统是否健康 +func (c *Client) IsHealthy(ctx context.Context) (bool, error) { + status, err := c.CheckHealth(ctx) + if err != nil { + return false, err + } + return status.Status == "ok", nil +} diff --git a/ragflow/worker_pool.go b/ragflow/worker_pool.go new file mode 100644 index 0000000..91eae0c --- /dev/null +++ b/ragflow/worker_pool.go @@ -0,0 +1,154 @@ +package ragflow + +import ( + "context" + "runtime/debug" + "strings" + "sync" + "time" + + "gitea.com/red-future/common/redis" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/grpool" +) + +// 默认批量大小(每次从 Redis 读取并发送的消息数) +const defaultBatchSize = 200 + +// QueueProcessor Stream 处理器,批量读取消息并发送到 RAGFlow +type QueueProcessor struct { + streamKey string // Stream 键名 + groupName string // 消费者组名称 + consumerName string // 消费者名称 + timeout int64 // 阻塞超时时间(毫秒) + batchSize int64 // 最大并发数(协程池大小) + stopChan chan struct{} // 停止信号 + pool *grpool.Pool // GoFrame协程池 + handleFunc func(ctx context.Context, message map[string]interface{}) error + processingMsgs sync.Map // 正在处理的消息ID(去重用) +} + +// NewQueueProcessor 创建 Stream 处理器 +func NewQueueProcessor(streamKey, groupName, consumerName string, timeout, batchSize int64, handleFunc func(ctx context.Context, message map[string]interface{}) error) *QueueProcessor { + // 创建协程池:固定大小,避免频繁创建销毁goroutine + pool := grpool.New(int(batchSize)) + + return &QueueProcessor{ + streamKey: streamKey, + groupName: groupName, + consumerName: consumerName, + timeout: timeout, + batchSize: batchSize, + stopChan: make(chan struct{}), + pool: pool, // 使用GoFrame协程池 + handleFunc: handleFunc, + } +} + +// Start 启动 Stream 处理器 +// 削峰填谷:每次读取 batchSize 条消息,并发发送,发完立刻读下一批 +func (q *QueueProcessor) Start(ctx context.Context) error { + glog.Infof(ctx, "Stream 处理器启动 - Stream: %s, 消费者组: %s, 消费者: %s, 批量大小: %d", + q.streamKey, q.groupName, q.consumerName, q.batchSize) + + // 确保 Consumer Group 存在(重试直到成功) + for { + if err := redis.CreateConsumerGroup(ctx, q.streamKey, q.groupName); err != nil { + // BUSYGROUP 表示已存在,不是错误 + if strings.Contains(err.Error(), "BUSYGROUP") { + glog.Debugf(ctx, "Consumer Group 已存在") + break + } + glog.Warningf(ctx, "创建 Consumer Group 失败: %v,1秒后重试", err) + time.Sleep(time.Second) + continue + } + glog.Infof(ctx, "Consumer Group 创建成功") + break + } + + for { + select { + case <-q.stopChan: + glog.Info(ctx, "Stream 处理器收到停止信号") + return nil + default: + // 1. 从 Redis Stream 读取一批消息 + messages, err := redis.ReadFromStream(ctx, q.streamKey, q.groupName, q.consumerName, q.batchSize, q.timeout) + if err != nil { + glog.Errorf(ctx, "从 Stream 读取消息失败: %v", err) + continue + } + + if len(messages) == 0 { + continue + } + + glog.Infof(ctx, "✅ 从Stream读取到 %d 条消息,开始处理", len(messages)) + + // 2. 去重+立即ACK:对话场景优先实时性,失败不重试 + for i, msg := range messages { + m := msg // 捕获循环变量 + msgIndex := i + 1 + + // 去重:如果消息正在处理,跳过 + if _, exists := q.processingMsgs.LoadOrStore(m.ID, true); exists { + glog.Debugf(ctx, "⏭️ 跳过正在处理的消息 - ID: %s", m.ID) + continue + } + + // 立即ACK:对话场景不需要重试,避免重复消费 + if err := redis.AckMessage(ctx, q.streamKey, q.groupName, m.ID); err != nil { + glog.Errorf(ctx, "确认消息失败: %v, 消息ID: %s", err, m.ID) + } + + glog.Infof(ctx, "📨 准备处理第 %d/%d 条消息 - ID: %s", msgIndex, len(messages), m.ID) + + // 提交到协程池,池满时会阻塞等待空闲worker + q.pool.Add(ctx, func(ctx context.Context) { + defer q.processingMsgs.Delete(m.ID) // 处理完成后移除标记 + q.processMessage(ctx, m) + }) + } + // 3. 立刻读下一批(不等待,协程池自动控制并发数) + } + } +} + +// processMessage 处理单条消息(异步执行) +func (q *QueueProcessor) processMessage(ctx context.Context, message redis.StreamMessage) { + // 捕获panic,防止协程崩溃 + defer func() { + if r := recover(); r != nil { + glog.Errorf(ctx, "❌ PANIC: 消息处理发生panic - 消息ID: %s, panic内容: %v\n堆栈:\n%s", + message.ID, r, debug.Stack()) + } + }() + + glog.Infof(ctx, "🔄 开始处理消息 - ID: %s", message.ID) + + // 打印实际字段名(调试用) + var fieldNames []string + for key := range message.Values { + fieldNames = append(fieldNames, key) + } + glog.Infof(ctx, "📋 消息字段名列表: %v", fieldNames) + glog.Infof(ctx, "📦 消息完整内容: %+v", message.Values) + + // 调用处理函数发送到 RAGFlow + if err := q.handleFunc(ctx, message.Values); err != nil { + glog.Errorf(ctx, "❌ 消息处理失败: %v, 消息ID: %s", err, message.ID) + } else { + glog.Infof(ctx, "✅ 消息处理成功 - ID: %s", message.ID) + } + + // ACK已在读取后立即执行,此处无需重复ACK + // 对话场景:失败直接丢弃,不重试(实时性优先) +} + +// Stop 停止队列处理器 +func (q *QueueProcessor) Stop() { + close(q.stopChan) + // 关闭协程池,等待所有任务完成 + q.pool.Close() +} diff --git a/redis/keys.go b/redis/keys.go new file mode 100644 index 0000000..ac62939 --- /dev/null +++ b/redis/keys.go @@ -0,0 +1,19 @@ +package redis + +// Redis 数据缓存 Key 常量 +const ( + CleanList = "list:tenantId-%v:collection-%s:*" // 清理列表Key + CleanCount = "count:tenantId-%v:collection-%s:*" // 清理计数Key + List = "list:tenantId-%v:collection-%s:filter:%s:options:%s" // 列表查询Key + Count = "count:tenantId-%v:collection-%s:filter:%s" // 计数查询Key + One = "one:tenantId-%v:collection-%s:filter:%s" // 单条查询Key +) + +// 限流 Redis Key 常量 +const ( + RateLimitKeyPrefix = "ragflow:ratelimit:" // 限流Key前缀 + RateLimitKeyIP = "ip:%s" // IP限流: ip:192.168.1.1 + RateLimitKeyUser = "user:%s" // 用户限流: user:123 或 user:anon:192.168.1.1 + RateLimitKeyService = "service:%s" // 服务限流: service:customerService + RateLimitKeyGlobal = "global:requests" // 全局限流: global:requests +) diff --git a/redis/message.go b/redis/message.go new file mode 100644 index 0000000..a9cfcbe --- /dev/null +++ b/redis/message.go @@ -0,0 +1,13 @@ +package redis + +import "context" + +type QueueMessage struct { + StreamKey string // Stream 键名 + GroupName string // 消费者组名称 + ConsumerName string // 消费者名称 + BatchSize int64 // 最大并发数(信号量容量) + BlockMs int64 // 阻塞时间 + AutoAck bool // ACK确认,true自动确认,false手动确认 + HandleFunc func(ctx context.Context, message map[string]interface{}) error +} diff --git a/redis/redis.go b/redis/redis.go new file mode 100644 index 0000000..8bcb742 --- /dev/null +++ b/redis/redis.go @@ -0,0 +1,778 @@ +package redis + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +var ( + // redisClient 内部使用的 Redis 客户端(单例模式) + redisClient *gredis.Redis + redisOnce sync.Once +) + +// getClient 获取 Redis 客户端(延迟初始化) +func getClient() *gredis.Redis { + redisOnce.Do(func() { + redisClient = g.Redis() + }) + return redisClient +} + +// getClient 获取 Redis 客户端 临时方法 +func GetRedisClientTest(name string) *gredis.Redis { + return g.Redis(name) +} + +// RedisClient 获取 Redis 客户端(函数式,确保单例正确初始化) +func RedisClient() *gredis.Redis { + return getClient() +} + +// Lock 分布式锁 +func Lock(ctx context.Context, key string, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) { + limit := 3 +LOOP: + if limit < 0 { + return false, errors.New("锁重试次数耗尽") + } + limit-- + client := getClient() + if val, err := client.Set(ctx, key, true, gredis.SetOption{ + TTLOption: gredis.TTLOption{ + EX: &expireSeconds, + }, + NX: true, + }); err != nil { + return false, err + } else { + if val.Bool() { + defer func(client *gredis.Redis, ctx context.Context, key string) { + if _, err = client.Del(ctx, key); err != nil { + glog.Errorf(ctx, "redis client Del error: %v", err) + } + }(client, ctx, key) + if err = fn(ctx); err != nil { + return false, err + } + return true, nil + } else { + time.Sleep(time.Second) + goto LOOP + } + } +} + +func GetReadStream(ctx context.Context, msg ...QueueMessage) error { + for _, t := range msg { + err := GetReadFromStream(ctx, t.StreamKey, t.GroupName, t.ConsumerName, t.BatchSize, t.BlockMs, t.AutoAck, t.HandleFunc) + if err != nil { + glog.Infof(ctx, "读取ReadFromStream数据失败-> 键名: %s, 消费者组: %s, 消费者名称%v\n, 失败err:%v\n", t.StreamKey, t.GroupName, t.ConsumerName, err) + continue + } + } + return nil +} + +// GetReadFromStream 读取ReadFromStream数据 +func GetReadFromStream(ctx context.Context, streamKey, groupName, consumerName string, count, blockMs int64, autoAck bool, fn func(ctx context.Context, message map[string]interface{}) error) (err error) { + glog.Infof(ctx, "初始化 Stream: %s, 消费者组: %s", streamKey, groupName) + err = InitStreamGroup(ctx, streamKey, groupName) + if err != nil { + return err + } + for { + // 从 Redis Stream 读取一批消息 + messages, err := ReadFromStream(ctx, streamKey, groupName, consumerName, count, blockMs) + if err != nil { + glog.Errorf(ctx, "[DEBUG Redis] XREADGROUP 错误: %v", err) + return err + } + // 处理消息 + for _, msg := range messages { + glog.Infof(ctx, "消费者 '%s' -> 接收到消息 ID: %s, 内容: %v\n", consumerName, msg.ID, msg.Values) + // 业务处理 + if err = fn(ctx, msg.Values); err != nil { + glog.Infof(ctx, "业务处理失败-> err:%v\n", err) + continue + } + // 确认消息 (ACK) + if autoAck { + // 处理成功后,必须调用 XAck,否则消息会一直留在 PEL 中 + err = AckMessage(ctx, streamKey, groupName, msg.ID) + if err != nil { + glog.Infof(ctx, "消费者 '%s' 确认消息 ID %s 失败: %v\n", consumerName, msg.ID, err) + } else { + glog.Infof(ctx, "消费者 '%s' -> 已确认消息 ID: %s\n", consumerName, msg.ID) + } + } + + } + } + return +} + +// Stream 和消费者组常量 +const ( + // RAGFlow 请求 Stream Key + RAGFlowRequestStreamKey = "ragflow:request:stream" + // RAGFlow 响应 Stream Key + RAGFlowResponseStreamKey = "ragflow:response:stream" + // RAGFlow 请求消费者组名称 + RAGFlowRequestConsumerGroup = "ragflow:request:consumer:group" + // RAGFlow 响应消费者组名称 + RAGFlowResponseConsumerGroup = "ragflow:response:consumer:group" + // RAGFlow 消费者组名称(兼容旧代码) + RAGFlowConsumerGroup = "ragflow:consumer:group" + // 会话最后活跃时间 Key 前缀 + SessionLastActiveKeyPrefix = "ragflow:session:" +) + +// StreamMessage Redis Stream 消息结构 +type StreamMessage struct { + ID string // 消息ID(自动生成) + Values map[string]interface{} // 消息内容 +} + +// InitStreamGroup 初始化 Stream 和消费者组 +// 使用 gredis Do() 方法执行 XGROUP CREATE 命令 +func InitStreamGroup(ctx context.Context, streamKey, groupName string) error { + // XGROUP CREATE streamKey groupName 0 MKSTREAM + _, err := getClient().Do(ctx, "XGROUP", "CREATE", streamKey, groupName, "0", "MKSTREAM") + if err != nil { + // 如果组已存在,忽略错误 + errStr := err.Error() + if strings.Contains(errStr, "BUSYGROUP") || strings.Contains(errStr, "already exists") { + return nil + } + return err + } + return nil +} + +// AddToStream 将消息添加到 Stream +// 使用 gredis Do() 方法执行 XADD 命令 +// msg 可以是结构体或 map,内部自动转换 +func AddToStream(ctx context.Context, streamKey string, msg interface{}) (messageID string, err error) { + // 将结构体转换为 map + values := gconv.Map(msg) + + // XADD streamKey * field1 value1 field2 value2 ... + args := make([]interface{}, 0, len(values)*2+2) + args = append(args, streamKey, "*") // "*" 自动生成ID + for key, val := range values { + args = append(args, key, val) + } + + result, err := getClient().Do(ctx, "XADD", args...) + if err != nil { + return + } + + messageID = result.String() + return +} + +// CreateConsumerGroup 创建消费者组(如果不存在) +// XGROUP CREATE streamKey groupName 0 MKSTREAM +// 使用0作为起始ID,从Stream开头读取所有未消费消息 +func CreateConsumerGroup(ctx context.Context, streamKey, groupName string) error { + _, err := getClient().Do(ctx, "XGROUP", "CREATE", streamKey, groupName, "0", "MKSTREAM") + return err +} + +// ReadFromStream 从 Stream 读取消息(消费者组模式) +// 使用 gredis Do() 方法执行 XREADGROUP 命令 +func ReadFromStream(ctx context.Context, streamKey, groupName, consumerName string, count int64, blockMs int64) ([]StreamMessage, error) { + // 检查是否需要记录trace(避免轮询产生大量trace) + execCtx := ctx + if !g.Cfg().MustGet(ctx, "jaeger.traceStream", true).Bool() { + // 不记录trace:使用background context(不继承span) + execCtx = context.Background() + } + +RECONNECT: + // 先尝试读取pending消息(ID=0),处理积压 + result, err := getClient().Do(execCtx, + "XREADGROUP", "GROUP", groupName, consumerName, + "COUNT", count, + "BLOCK", 0, // 不阻塞,立即返回 + "STREAMS", streamKey, "0", // ID=0 读取pending消息 + ) + if err != nil { + g.Log().Errorf(ctx, "❌ XREADGROUP读取pending失败: stream=%s, error=%v", streamKey, err) + time.Sleep(time.Second) + goto RECONNECT + } + + // 检查pending结果是否为空(需要检查消息数组是否为空) + hasPending := false + if result != nil && !result.IsEmpty() { + // 尝试解析map格式 + if resultVal := result.Val(); resultVal != nil { + if streamsMap, ok := resultVal.(map[interface{}]interface{}); ok { + for _, streamMsgs := range streamsMap { + if msgsArray, ok := streamMsgs.([]interface{}); ok && len(msgsArray) > 0 { + hasPending = true + break + } + } + } + } + } + + // 如果没有pending消息,读取新消息 + if !hasPending { + result, err = getClient().Do(execCtx, + "XREADGROUP", "GROUP", groupName, consumerName, + "COUNT", count, + "BLOCK", blockMs, + "STREAMS", streamKey, ">", + ) + if err != nil { + g.Log().Errorf(ctx, "❌ XREADGROUP读取新消息失败: stream=%s, error=%v", streamKey, err) + time.Sleep(time.Second) + goto RECONNECT + } + } + + // 预分配容量,避免动态扩容 + messages := make([]StreamMessage, 0, int(count)) + + if result == nil || result.IsEmpty() { + // 超时或没有数据 + return messages, nil + } + + // GoFrame gredis 返回格式: map[streamKey:[[msgID [field1 value1 field2 value2 ...]] ...]] + resultVal := result.Val() + + // 尝试 map 格式(GoFrame gredis 返回) + if streamsMap, ok := resultVal.(map[interface{}]interface{}); ok { + for streamKey, streamMsgs := range streamsMap { + msgsArray, ok := streamMsgs.([]interface{}) + if !ok { + g.Log().Errorf(ctx, "❌ streamMsgs类型转换失败: streamKey=%v, 实际类型=%T", streamKey, streamMsgs) + continue + } + for i, msgData := range msgsArray { + msgArray, ok := msgData.([]interface{}) + if !ok { + g.Log().Errorf(ctx, "❌ msgData类型转换失败: index=%d, 实际类型=%T", i, msgData) + continue + } + if len(msgArray) < 2 { + g.Log().Errorf(ctx, "❌ msgArray长度不足: index=%d, len=%d", i, len(msgArray)) + continue + } + msgID := gconv.String(msgArray[0]) + fieldsArray, ok := msgArray[1].([]interface{}) + if !ok { + g.Log().Errorf(ctx, "❌ fieldsArray类型转换失败: msgID=%s, msgArray[1]类型=%T", msgID, msgArray[1]) + continue + } + values := make(map[string]interface{}, len(fieldsArray)/2) + for i := 0; i < len(fieldsArray); i += 2 { + if i+1 < len(fieldsArray) { + key := gconv.String(fieldsArray[i]) + values[key] = fieldsArray[i+1] + } + } + messages = append(messages, StreamMessage{ + ID: msgID, + Values: values, + }) + } + } + if len(messages) == 0 { + g.Log().Errorf(ctx, "❌ [ReadFromStream] map格式解析失败: streamsMap长度=%d, 但未提取到消息", len(streamsMap)) + } + return messages, nil + } + + // 尝试数组格式(标准 Redis 返回) + if streamsArray, ok := resultVal.([]interface{}); ok && len(streamsArray) > 0 { + for _, streamData := range streamsArray { + streamArray, ok := streamData.([]interface{}) + if !ok || len(streamArray) < 2 { + continue + } + messagesArray, ok := streamArray[1].([]interface{}) + if !ok { + continue + } + for _, msgData := range messagesArray { + msgArray, ok := msgData.([]interface{}) + if !ok || len(msgArray) < 2 { + continue + } + msgID := gconv.String(msgArray[0]) + fieldsArray, ok := msgArray[1].([]interface{}) + if !ok { + continue + } + values := make(map[string]interface{}, len(fieldsArray)/2) + for i := 0; i < len(fieldsArray); i += 2 { + if i+1 < len(fieldsArray) { + key := gconv.String(fieldsArray[i]) + values[key] = fieldsArray[i+1] + } + } + messages = append(messages, StreamMessage{ + ID: msgID, + Values: values, + }) + } + } + if len(messages) == 0 { + g.Log().Errorf(ctx, "❌ [ReadFromStream] 数组格式解析失败: streamsArray长度=%d, 但未提取到消息", len(streamsArray)) + } + return messages, nil + } + + g.Log().Errorf(ctx, "❌ [ReadFromStream] 无法识别的result格式, resultVal类型: %T, 值: %+v", resultVal, resultVal) + return messages, nil +} + +// AckMessage 确认消息已处理 +// 使用 gredis Do() 方法执行 XACK 命令 +func AckMessage(ctx context.Context, streamKey, groupName string, messageIDs ...string) error { + // XACK streamKey groupName messageID1 messageID2 ... + // 预分配容量,避免动态扩容 + args := make([]interface{}, 0, len(messageIDs)+2) + args = append(args, streamKey, groupName) + for _, id := range messageIDs { + args = append(args, id) + } + + _, err := getClient().Do(ctx, "XACK", args...) + return err +} + +// GetStreamLength 获取 Stream 当前长度 +// 使用 gredis Do() 方法执行 XLEN 命令 +func GetStreamLength(ctx context.Context, streamKey string) (int64, error) { + // XLEN streamKey + result, err := getClient().Do(ctx, "XLEN", streamKey) + if err != nil { + return 0, err + } + + length := gconv.Int64(result) + return length, nil +} + +// PendingMessage Pending 消息结构 +type PendingMessage struct { + ID string // 消息ID + Consumer string // 消费者名称 + Idle int64 // 空闲时间(毫秒) + RetryCount int64 // 重试次数 +} + +// GetPendingMessages 获取待处理消息 +// 使用 gredis Do() 方法执行 XPENDING 命令 +func GetPendingMessages(ctx context.Context, streamKey, groupName string, start, end string, count int64) ([]PendingMessage, error) { + // XPENDING streamKey groupName start end count + result, err := getClient().Do(ctx, "XPENDING", streamKey, groupName, start, end, count) + if err != nil { + return nil, err + } + + if result == nil { + return nil, nil + } + + // 解析返回值:[[ID, consumer, idle, retryCount], ...] + pendingArray, ok := result.Val().([]interface{}) + if !ok { + return nil, nil + } + + messages := make([]PendingMessage, 0, len(pendingArray)) + for _, item := range pendingArray { + itemArray, ok := item.([]interface{}) + if !ok || len(itemArray) < 4 { + continue + } + + messages = append(messages, PendingMessage{ + ID: gconv.String(itemArray[0]), + Consumer: gconv.String(itemArray[1]), + Idle: gconv.Int64(itemArray[2]), + RetryCount: gconv.Int64(itemArray[3]), + }) + } + + return messages, nil +} + +// ClaimPendingMessage 认领超时的 Pending 消息 +// 使用 gredis Do() 方法执行 XCLAIM 命令 +func ClaimPendingMessage(ctx context.Context, streamKey, groupName, consumerName string, minIdleTime int64, messageIDs ...string) ([]StreamMessage, error) { + // XCLAIM streamKey groupName consumerName minIdleTime messageID1 messageID2 ... + args := []interface{}{streamKey, groupName, consumerName, minIdleTime} + for _, id := range messageIDs { + args = append(args, id) + } + + result, err := getClient().Do(ctx, "XCLAIM", args...) + if err != nil { + return nil, err + } + + if result == nil { + return nil, nil + } + + // 解析返回值:类似 XREADGROUP + messagesArray, ok := result.Val().([]interface{}) + if !ok { + return nil, nil + } + + // 预分配容量,避免动态扩容 + messages := make([]StreamMessage, 0, len(messagesArray)) + for _, msgData := range messagesArray { + msgArray, ok := msgData.([]interface{}) + if !ok || len(msgArray) < 2 { + continue + } + + msgID := gconv.String(msgArray[0]) + fieldsArray, ok := msgArray[1].([]interface{}) + if !ok { + continue + } + + // 预分配 map 容量 ,避免动态扩容 + values := make(map[string]interface{}, len(fieldsArray)/2) + for i := 0; i < len(fieldsArray); i += 2 { + if i+1 < len(fieldsArray) { + key := gconv.String(fieldsArray[i]) + values[key] = fieldsArray[i+1] + } + } + + messages = append(messages, StreamMessage{ + ID: msgID, + Values: values, + }) + } + + return messages, nil +} + +// SetSessionLastActive 设置用户最后活跃时间 +// 使用 gredis SetEX 方法 +func SetSessionLastActive(ctx context.Context, userId string) error { + key := SessionLastActiveKeyPrefix + userId + ":last_active" + timestamp := gtime.Now().Timestamp() + + // SETEX key 7200 value (7200秒 = 2小时) + _, err := getClient().Do(ctx, "SETEX", key, 7200, timestamp) + return err +} + +// GetSessionLastActive 获取用户最后活跃时间 +// 使用 gredis Get 方法 +func GetSessionLastActive(ctx context.Context, userId string) (int64, error) { + key := SessionLastActiveKeyPrefix + userId + ":last_active" + result, err := getClient().Get(ctx, key) + if err != nil { + return 0, err + } + + if result.IsEmpty() { + return 0, nil + } + + timestamp := gconv.Int64(result.Val()) + return timestamp, nil +} + +// IsUserActive 检查用户是否在指定时间范围内活跃过 +// 用于追问逻辑:如果用户最近活跃过,则不发送追问消息 +// 参数: +// - userId: 用户ID +// - seconds: 时间范围(秒),例如传入300表示检查5分钟内是否活跃 +// +// 返回: +// - bool: true表示用户在指定时间内活跃过 +// - error: 操作失败时返回错误 +func IsUserActive(ctx context.Context, userId string, seconds int64) (bool, error) { + lastActive, err := GetSessionLastActive(ctx, userId) + if err != nil { + return false, err + } + + if lastActive == 0 { + return false, nil // 未找到记录,视为不活跃 + } + + // 检查时间差 + now := gtime.Now().Timestamp() + return (now - lastActive) < seconds, nil +} + +// ============== 限流相关 ============== + +// IncrRateLimit 增加限流计数器,返回当前计数 +// key: 限流key(需要包含完整路径,如 "ip:192.168.1.1") +// windowSeconds: 时间窗口(秒) +func IncrRateLimit(ctx context.Context, key string, windowSeconds int64) (count int64, err error) { + fullKey := RateLimitKeyPrefix + key + result, err := getClient().Do(ctx, "INCR", fullKey) + if err != nil { + return + } + count = result.Int64() + + // 首次设置过期时间 + if count == 1 { + getClient().Do(ctx, "EXPIRE", fullKey, windowSeconds) + } + return +} + +// GetRateLimit 获取当前限流计数 +func GetRateLimit(ctx context.Context, key string) (count int64, err error) { + fullKey := RateLimitKeyPrefix + key + result, err := getClient().Get(ctx, fullKey) + if err != nil { + return + } + if result.IsEmpty() { + return 0, nil + } + count = result.Int64() + return +} + +// SetSessionCache 缓存 RAGFlow Session ID(租户+用户隔离) +func SetSessionCache(ctx context.Context, tenantId, userId, sessionId string) error { + key := SessionLastActiveKeyPrefix + tenantId + ":" + userId + ":session_id" + // SETEX key 7200 value (7200秒 = 2小时,与last_active保持一致) + _, err := getClient().Do(ctx, "SETEX", key, 7200, sessionId) + return err +} + +// GetSessionCache 获取缓存的 RAGFlow Session ID(租户+用户隔离) +func GetSessionCache(ctx context.Context, tenantId, userId string) (string, error) { + key := SessionLastActiveKeyPrefix + tenantId + ":" + userId + ":session_id" + result, err := getClient().Get(ctx, key) + if err != nil { + return "", err + } + + if result.IsEmpty() { + return "", nil + } + + return result.String(), nil +} + +// DelSessionCache 删除缓存的 RAGFlow Session ID(归档时调用,租户+用户隔离) +func DelSessionCache(ctx context.Context, tenantId, userId string) error { + key := SessionLastActiveKeyPrefix + tenantId + ":" + userId + ":session_id" + _, err := getClient().Del(ctx, key) + return err +} + +// TryLock 尝试获取分布式锁(非阻塞) +// key: 锁的键名 +// expireSeconds: 锁的过期时间(秒),防止死锁 +// 返回 true 表示获取成功,false 表示锁已被其他节点持有 +func TryLock(ctx context.Context, key string, expireSeconds int) bool { + // SET key value NX EX expireSeconds + result, err := getClient().Do(ctx, "SET", key, gtime.Now().String(), "NX", "EX", expireSeconds) + if err != nil { + glog.Errorf(ctx, "获取分布式锁失败: %v", err) + return false + } + return result.String() == "OK" +} + +// Unlock 释放分布式锁 +func Unlock(ctx context.Context, key string) { + if _, err := getClient().Del(ctx, key); err != nil { + glog.Errorf(ctx, "释放分布式锁失败: %v", err) + } +} + +// ============== 对话计数相关(用于卡片触发)============== + +const ( + // UserStateKeyPrefix 用户会话状态 Key 前缀(融合阶段+计数) + UserStateKeyPrefix = "ragflow:user:state:" + // UserStateExpireSeconds 用户状态过期时间(5分钟) + UserStateExpireSeconds = 300 +) + +// UserState 用户会话状态(阶段+对话计数+咨询方向,统一5分钟过期) +type UserState struct { + Stage int `json:"stage"` // 当前阶段 + Direction string `json:"direction"` // 咨询方向 + Count int64 `json:"count"` // 对话计数(v5.2卡片触发) + AccountName string `json:"accountName"` // 用户选择的方向对应的客服账号名称 +} + +// GetUserState 获取用户状态(阶段+计数) +func GetUserState(ctx context.Context, userId, platform string) (state *UserState, err error) { + key := UserStateKeyPrefix + userId + "_" + platform + result, err := getClient().Do(ctx, "HGETALL", key) + if err != nil { + return + } + + state = &UserState{Stage: 5} // 默认状态5(未选择方向) + if result.IsEmpty() { + // Redis为空,初始化默认状态 + if initErr := SetUserStage(ctx, userId, platform, 5); initErr != nil { + err = initErr + return + } + return + } + + m := result.Map() + state.Stage = gconv.Int(m["stage"]) + state.Count = gconv.Int64(m["count"]) + state.Direction = gconv.String(m["direction"]) + return +} + +// SetUserStage 设置用户阶段,并刷新过期时间 +func SetUserStage(ctx context.Context, userId, platform string, stage int) error { + key := UserStateKeyPrefix + userId + "_" + platform + _, err := getClient().Do(ctx, "HSET", key, "stage", stage) + if err != nil { + return err + } + _, err = getClient().Do(ctx, "EXPIRE", key, UserStateExpireSeconds) + return err +} + +// SetUserAccountName 设置用户对应的客服账号名称,并刷新过期时间 +func SetUserAccountName(ctx context.Context, userId, platform, accountName string) error { + key := UserStateKeyPrefix + userId + "_" + platform + _, err := getClient().Do(ctx, "HSET", key, "accountName", accountName) + if err != nil { + return err + } + _, err = getClient().Do(ctx, "EXPIRE", key, UserStateExpireSeconds) + return err +} + +// SetUserDirection 设置用户选择的咨询方向,并刷新过期时间 +func SetUserDirection(ctx context.Context, userId, platform, direction string) error { + key := UserStateKeyPrefix + userId + "_" + platform + _, err := getClient().Do(ctx, "HSET", key, "direction", direction) + if err != nil { + return err + } + _, err = getClient().Do(ctx, "EXPIRE", key, UserStateExpireSeconds) + return err +} + +// IncrUserCount 增加用户对话计数,返回当前轮数,并刷新过期时间 +func IncrUserCount(ctx context.Context, userId, platform string) (count int64, err error) { + key := UserStateKeyPrefix + userId + "_" + platform + result, err := getClient().Do(ctx, "HINCRBY", key, "count", 1) + if err != nil { + return + } + count = result.Int64() + _, err = getClient().Do(ctx, "EXPIRE", key, UserStateExpireSeconds) + return +} + +// ResetUserState 重置用户状态(归档时调用) +func ResetUserState(ctx context.Context, userId, platform string) error { + key := UserStateKeyPrefix + userId + "_" + platform + _, err := getClient().Del(ctx, key) + return err +} + +// ============== 对话缓存相关(5句落库)============== + +const ( + // ConversationCacheKeyPrefix 对话缓存 Key 前缀 + ConversationCacheKeyPrefix = "ragflow:conversation:cache:" + // ConversationCacheExpireSeconds 对话缓存过期时间(10分钟) + ConversationCacheExpireSeconds = 600 +) + +// CacheConversation 缓存单条对话到Redis List(按sessionId存储) +func CacheConversation(ctx context.Context, sessionId string, data []byte) error { + key := ConversationCacheKeyPrefix + sessionId + _, err := getClient().Do(ctx, "RPUSH", key, string(data)) + if err != nil { + return err + } + _, err = getClient().Do(ctx, "EXPIRE", key, ConversationCacheExpireSeconds) + return err +} + +// GetCachedConversations 获取缓存的对话列表并清空(按sessionId查询) +func GetCachedConversations(ctx context.Context, sessionId string) (list []string, err error) { + key := ConversationCacheKeyPrefix + sessionId + result, err := getClient().Do(ctx, "LRANGE", key, 0, -1) + if err != nil { + return + } + if result.IsEmpty() { + return + } + list = result.Strings() + // 清空缓存 + getClient().Del(ctx, key) + return +} + +// GetCachedConversationCount 获取缓存的对话数量(按sessionId查询) +func GetCachedConversationCount(ctx context.Context, sessionId string) (count int64, err error) { + key := ConversationCacheKeyPrefix + sessionId + result, err := getClient().Do(ctx, "LLEN", key) + if err != nil { + return + } + return result.Int64(), nil +} + +// ClearCachedConversations 清空对话缓存(归档时调用,按sessionId) +func ClearCachedConversations(ctx context.Context, sessionId string) error { + key := ConversationCacheKeyPrefix + sessionId + _, err := getClient().Del(ctx, key) + return err +} + +// ========== 以下为兼容旧接口(内部调用新实现)========== + +// IncrConversationCount 增加用户对话计数(兼容旧接口) +func IncrConversationCount(ctx context.Context, userId, platform string, _ int64) (count int64, err error) { + return IncrUserCount(ctx, userId, platform) +} + +// GetConversationCount 获取用户当前对话轮数(兼容旧接口) +func GetConversationCount(ctx context.Context, userId, platform string) (count int64, err error) { + state, err := GetUserState(ctx, userId, platform) + if err != nil { + return + } + return state.Count, nil +} + +// ResetConversationCount 重置用户对话计数(兼容旧接口) +func ResetConversationCount(ctx context.Context, userId, platform string) error { + return ResetUserState(ctx, userId, platform) +} diff --git a/redis/types.go b/redis/types.go new file mode 100644 index 0000000..1e5a83f --- /dev/null +++ b/redis/types.go @@ -0,0 +1,129 @@ +package redis + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" +) + +// HistoryMessage 历史消息结构(用于上下文注入) +type HistoryMessage struct { + Question string `json:"question"` // 用户问题 + Answer string `json:"answer"` // AI 回复 +} + +// SendStreamMessage 发送到 Redis Stream 的消息结构 +type SendStreamMessage struct { + UserId string `json:"userId"` // 用户ID + Content string `json:"content"` // 消息内容 + Timestamp int64 `json:"timestamp"` // 时间戳(秒) + MessageId string `json:"messageId"` // 消息唯一ID + Platform string `json:"platform,omitempty"` // 平台标识 + AccountId string `json:"accountId,omitempty"` // 账号ID + TenantId string `json:"tenantId,omitempty"` // 租户ID(数据隔离) + AccountName string `json:"accountName,omitempty"` // 客服账号名称 + ChatId string `json:"chatId,omitempty"` // RAGFlow Chat ID(从ragflow_config查询) + ReplyQueue string `json:"replyQueue,omitempty"` // 响应队列名称(支持多实例独立队列) + History []HistoryMessage `json:"history,omitempty"` // 历史对话(归档后恢复时携带) +} + +// BatchStreamMessage 批量消息结构 +type BatchStreamMessage struct { + UserId string `json:"userId"` // 用户ID + Content string `json:"content"` // 消息内容 + Timestamp int64 `json:"timestamp"` // 时间戳(秒) + BatchId string `json:"batchId"` // 批次ID + Index int `json:"index"` // 批次内序号 +} + +// ResponseStreamMessage RAGFlow 响应消息结构(MQ 消息) +type ResponseStreamMessage struct { + UserId string `json:"userId"` // 用户ID + Platform string `json:"platform"` // 平台标识 + TenantId string `json:"tenantId"` // 租户ID + AccountId string `json:"accountId,omitempty"` // 账号ID + AccountName string `json:"accountName,omitempty"` // 客服账号名称 + Question string `json:"question"` // 用户问题 + Content string `json:"content"` // RAGFlow 回复内容 + SessionId string `json:"sessionId"` // RAGFlow Session ID + Timestamp int64 `json:"timestamp"` // 时间戳(秒) + MessageId string `json:"messageId"` // 原始消息ID +} + +// FollowUpMessage 追问消息结构(RabbitMQ 延时队列) +type FollowUpMessage struct { + TenantId string `json:"tenantId"` // 租户ID + UserId string `json:"userId"` // 用户ID + Platform string `json:"platform"` // 平台标识 + Content string `json:"content"` // 追问内容 + FollowUpType int `json:"followUpType"` // 追问类型:1=30s, 2=60s, 3=180s + Timestamp int64 `json:"timestamp"` // 发送时间戳 +} + +// 追问类型常量 +const ( + FollowUpType1 = 1 // 第一次追问 + FollowUpType2 = 2 // 第二次追问 + FollowUpType3 = 3 // 第三次追问 +) + +// GetFollowUpContent 获取追问话术(从 config.yml 读取) +func GetFollowUpContent(followUpType int) string { + ctx := context.Background() + contents := g.Cfg().MustGet(ctx, "followUp.contents").Strings() + if len(contents) == 0 { + return "" + } + // followUpType: 1,2,3 对应数组索引 0,1,2 + index := followUpType - 1 + if index >= 0 && index < len(contents) { + return contents[index] + } + return "" +} + +// GetFollowUpDelay 获取追问延时(从 config.yml 读取) +func GetFollowUpDelay(followUpType int) int { + ctx := context.Background() + delays := g.Cfg().MustGet(ctx, "followUp.delays").Ints() + if len(delays) == 0 { + return 30 // 默认30秒 + } + // followUpType: 1,2,3 对应数组索引 0,1,2 + index := followUpType - 1 + if index >= 0 && index < len(delays) { + return delays[index] + } + return 30 +} + +// ArchiveMessage 会话归档消息结构(RabbitMQ 延时队列) +type ArchiveMessage struct { + UserId string `json:"userId"` // 用户ID + Platform string `json:"platform"` // 平台标识 + SessionId string `json:"sessionId"` // RAGFlow Session ID + TenantId string `json:"tenantId"` // 租户ID + Timestamp int64 `json:"timestamp"` // 发送时间戳 +} + +// GetArchiveDelay 获取归档延时(从 config.yml 读取) +func GetArchiveDelay() int { + ctx := context.Background() + return g.Cfg().MustGet(ctx, "archive.delay", 3600).Int() // 默认3600秒(1小时) +} + +// GetHistoryContextLimit 获取历史上下文轮数(从 config.yml 读取) +func GetHistoryContextLimit() int64 { + ctx := context.Background() + return g.Cfg().MustGet(ctx, "history.contextLimit", 5).Int64() // 默认5轮对话 +} + +// DocSyncMessage 文档同步消息结构(RAGFlow与MongoDB同步) +type DocSyncMessage struct { + DocId string `json:"docId"` // MongoDB文档ID + RagflowDocId string `json:"ragflowDocId"` // RAGFlow文档ID + TenantId string `json:"tenantId"` // 租户ID + DocType string `json:"docType"` // 文档类型:speechcraft/product + Action string `json:"action"` // 操作类型:sync_ragflow_id + Timestamp int64 `json:"timestamp"` // 时间戳 +} diff --git a/rpc/rpcx.go b/rpc/rpcx.go new file mode 100644 index 0000000..1843f0d --- /dev/null +++ b/rpc/rpcx.go @@ -0,0 +1,364 @@ +package rpc + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "gitea.com/red-future/common/consul" + "gitea.com/red-future/common/jaeger" + "github.com/gogf/gf/v2/frame/g" + rpcxClient "github.com/smallnest/rpcx/client" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var ( + // pluginsContainer rpcx插件容器(全局统一设置) + // init()中添加链路追踪插件,所有client共用此容器 + pluginsContainer = rpcxClient.NewPluginContainer() + + // clientPool 连接池缓存,key为服务名,value为客户端实例 + clientPool = make(map[string]*rpcxClient.OneClient) + + // poolMutex 连接池锁 + poolMutex sync.RWMutex + + // healthCheckInterval 健康检查间隔(秒) + healthCheckInterval = 30 + + // lastHealthCheckTime 上次健康检查时间,key为服务名 + lastHealthCheckTime = make(map[string]time.Time) + + // serviceAddrCache 服务地址缓存,key为服务名,value为地址 + serviceAddrCache = make(map[string]string) +) + +func init() { + // 全局设置链路追踪插件,所有client共用 + pluginsContainer.Add(&TracingPlugin{}) + + // 启动后台健康检查协程 + go healthCheckLoop() +} + +// healthCheckLoop 后台健康检查循环 +func healthCheckLoop() { + ticker := time.NewTicker(time.Duration(healthCheckInterval) * time.Second) + defer ticker.Stop() + + for range ticker.C { + checkAllConnections() + } +} + +// checkAllConnections 检查所有缓存连接的健康状态 +func checkAllConnections() { + poolMutex.Lock() + defer poolMutex.Unlock() + + now := time.Now() + for serviceName, client := range clientPool { + // 检查连接是否需要健康检查 + if lastCheck, ok := lastHealthCheckTime[serviceName]; ok { + if now.Sub(lastCheck) < time.Duration(healthCheckInterval)*time.Second { + continue + } + } + + ctx := context.Background() + + // 检查连接健康状态(心跳检测) + if !isClientHealthy(ctx, client, serviceName) { + g.Log().Warningf(ctx, "检测到服务[%s]连接不健康,将从连接池移除", serviceName) + client.Close() + delete(clientPool, serviceName) + delete(lastHealthCheckTime, serviceName) + delete(serviceAddrCache, serviceName) + continue + } + + // 连接健康,检查服务地址是否发生变化 + currentAddr, err := consul.GetInstanceAddr(ctx, serviceName) + if err != nil { + g.Log().Warningf(ctx, "健康检查时从consul获取服务[%s]地址失败: %v,保持现有连接", serviceName, err) + lastHealthCheckTime[serviceName] = now + continue + } + + // 检查地址是否发生变化 + if oldAddr, ok := serviceAddrCache[serviceName]; ok && oldAddr != currentAddr { + g.Log().Infof(ctx, "检测到服务[%s]地址变化: %s -> %s,重建连接", serviceName, oldAddr, currentAddr) + // 关闭旧连接并从连接池移除,下次请求时会创建新连接 + client.Close() + delete(clientPool, serviceName) + delete(lastHealthCheckTime, serviceName) + // 更新缓存的新地址 + serviceAddrCache[serviceName] = currentAddr + } else { + // 地址未变化,更新检查时间 + if !ok { + serviceAddrCache[serviceName] = currentAddr + } + g.Log().Debugf(ctx, "服务[%s]地址未变化,连接健康", serviceName) + } + + lastHealthCheckTime[serviceName] = now + } +} + +// isClientHealthy 检查client是否健康 +// 使用心跳检测方式:尝试调用服务的心跳方法 +func isClientHealthy(ctx context.Context, client *rpcxClient.OneClient, serviceName string) bool { + if client == nil { + return false + } + + // 设置较短的超时时间,避免阻塞 + pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + // 尝试调用健康检查方法 + // 大多数服务都会提供 Ping 或 Health 方法 + // 如果服务没有提供这些方法,会返回错误,我们认为是健康的 + // 因为连接本身是正常的,只是方法不存在 + var reply interface{} + err := client.Call(pingCtx, serviceName, "Ping", nil, &reply) + + // 如果调用成功,连接肯定健康 + if err == nil { + return true + } + + // 如果是方法不存在的错误,说明连接是健康的,只是服务没有Ping方法 + // 这种情况下我们认为是健康的 + if isMethodNotFoundError(err) || isServiceNotFoundError(err) { + return true + } + + // 其他错误(网络错误、超时等)说明连接不健康 + g.Log().Warningf(ctx, "健康检查失败,服务[%s]连接可能不健康: %v", serviceName, err) + return false +} + +// isMethodNotFoundError 判断是否是方法未找到错误 +func isMethodNotFoundError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + // rpcx 方法不存在的常见错误信息 + return strings.Contains(errStr, "not found") || + strings.Contains(errStr, "no such") || + strings.Contains(errStr, "service not found") || + strings.Contains(errStr, "method not found") +} + +// isServiceNotFoundError 判断是否是服务未找到错误 +func isServiceNotFoundError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "no service") || + strings.Contains(errStr, "service not registered") +} + +// getOrCreateClient 从连接池获取或创建客户端(带连接池) +func getOrCreateClient(ctx context.Context, serviceName string) (*rpcxClient.OneClient, error) { + if g.IsEmpty(serviceName) { + return nil, errors.New("服务名称不能为空") + } + + // 先尝试从连接池获取 + poolMutex.RLock() + client, exists := clientPool[serviceName] + poolMutex.RUnlock() + + // 如果存在且健康,直接返回 + if exists && isClientHealthy(ctx, client, serviceName) { + g.Log().Debugf(ctx, "从连接池获取rpcx客户端[%s]", serviceName) + return client, nil + } + + // 不存在或不健康,重新创建 + poolMutex.Lock() + defer poolMutex.Unlock() + + // 双重检查,防止并发时重复创建 + if client, exists := clientPool[serviceName]; exists && isClientHealthy(ctx, client, serviceName) { + return client, nil + } + + // 获取服务实例地址 + addr, err := consul.GetInstanceAddr(ctx, serviceName) + if err != nil { + g.Log().Errorf(ctx, "从consul获取服务[%s]地址失败: %v", serviceName, err) + return nil, err + } + + g.Log().Debugf(ctx, "服务[%s]地址: %s", serviceName, addr) + + // 缓存服务地址,用于健康检查时对比 + serviceAddrCache[serviceName] = addr + + // 创建服务发现 + discovery, err := rpcxClient.NewPeer2PeerDiscovery("tcp@"+addr, "") + if err != nil { + g.Log().Errorf(ctx, "创建服务发现失败: %v", err) + return nil, err + } + + // 创建新客户端 + newClient := rpcxClient.NewOneClient( + rpcxClient.Failtry, + rpcxClient.RandomSelect, + discovery, + rpcxClient.DefaultOption, + ) + newClient.SetPlugins(pluginsContainer) + + // 更新连接池 + if oldClient, ok := clientPool[serviceName]; ok && oldClient != nil { + oldClient.Close() + } + clientPool[serviceName] = newClient + lastHealthCheckTime[serviceName] = time.Now() + + g.Log().Infof(ctx, "rpcx客户端[%s]创建并加入连接池", serviceName) + + return newClient, nil +} + +// Call 调用rpcx服务方法 +// serviceName: 服务名称 +// serviceMethod: 服务方法 +// args: 请求参数 +// reply: 响应结果 +func Call(ctx context.Context, serviceName, serviceMethod string, args interface{}, reply interface{}) error { + // 从连接池获取客户端(不再关闭连接) + client, err := getOrCreateClient(ctx, serviceName) + if err != nil { + g.Log().Errorf(ctx, "获取rpcx客户端失败: %v", err) + return err + } + + // 设置超时 + callCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // 调用服务方法 + err = client.Call(callCtx, serviceName, serviceMethod, args, reply) + if err != nil { + g.Log().Errorf(ctx, "调用服务[%s]方法[%s]失败: %v", serviceName, serviceMethod, err) + + // 如果调用失败,检查连接是否需要重新创建 + poolMutex.Lock() + if pooledClient, ok := clientPool[serviceName]; ok && pooledClient == client { + // 标记为不健康,下次请求时会重新创建 + delete(lastHealthCheckTime, serviceName) + } + poolMutex.Unlock() + + return err + } + + return nil +} + +// Close 关闭指定服务的连接(用于清理连接池) +func Close(serviceName string) { + poolMutex.Lock() + defer poolMutex.Unlock() + + if client, ok := clientPool[serviceName]; ok { + client.Close() + delete(clientPool, serviceName) + delete(lastHealthCheckTime, serviceName) + delete(serviceAddrCache, serviceName) + g.Log().Infof(context.Background(), "rpcx客户端[%s]已从连接池移除", serviceName) + } +} + +// CloseAll 关闭所有连接(用于优雅停机) +func CloseAll() { + poolMutex.Lock() + defer poolMutex.Unlock() + + for serviceName, client := range clientPool { + client.Close() + g.Log().Infof(context.Background(), "rpcx客户端[%s]已关闭", serviceName) + } + clientPool = make(map[string]*rpcxClient.OneClient) + lastHealthCheckTime = make(map[string]time.Time) + serviceAddrCache = make(map[string]string) +} + +// TracingPlugin rpcx链路追踪插件 +// 实现 rpcx 的 PreCallPlugin 和 PostCallPlugin 接口 +type TracingPlugin struct{} + +// PreCall 调用前拦截 - 创建jaeger span +func (p *TracingPlugin) PreCall(ctx context.Context, serviceName, serviceMethod string, args interface{}) (err error) { + // 创建span,名称格式: ServiceName.Method + spanName := serviceName + "." + serviceMethod + ctx, span := jaeger.NewSpan(ctx, spanName) + + // 记录服务和方法信息 + span.SetAttributes( + attribute.String("rpc.service", serviceName), + attribute.String("rpc.method", serviceMethod), + attribute.String("rpc.system", "rpcx"), + ) + var data []byte + // 记录请求参数(序列化为JSON) + if args != nil { + if data, err = json.Marshal(args); err == nil { + argsStr := string(data) + // 限制长度,避免过大 + if len(argsStr) > 2000 { + argsStr = argsStr[:2000] + "... (truncated)" + } + span.SetAttributes(attribute.String("rpc.request", argsStr)) + } + } + + g.Log().Debugf(ctx, "[rpcx] 调用开始: %s.%s", serviceName, serviceMethod) + + return +} + +// PostCall 调用后拦截 - 记录结果和错误 +func (p *TracingPlugin) PostCall(ctx context.Context, serviceName, serviceMethod string, args interface{}, reply interface{}, err error) error { + span := trace.SpanFromContext(ctx) + if span != nil && span.IsRecording() { + defer span.End() + + // 记录响应结果 + if reply != nil { + if data, err := json.Marshal(reply); err == nil { + replyStr := string(data) + // 限制长度,避免过大 + if len(replyStr) > 2000 { + replyStr = replyStr[:2000] + "... (truncated)" + } + span.SetAttributes(attribute.String("rpc.response", replyStr)) + } + } + + // 处理错误 + if err != nil { + jaeger.RecordError(ctx, err, "rpcx调用失败") + span.SetStatus(codes.Error, err.Error()) + g.Log().Errorf(ctx, "[rpcx] 调用失败: %s.%s, 错误: %v", serviceName, serviceMethod, err) + } else { + g.Log().Debugf(ctx, "[rpcx] 调用成功: %s.%s", serviceName, serviceMethod) + } + } + + return nil +} diff --git a/swagger/swagger.go b/swagger/swagger.go new file mode 100644 index 0000000..76585aa --- /dev/null +++ b/swagger/swagger.go @@ -0,0 +1,130 @@ +package swagger + +import ( + "fmt" + "gitea.com/red-future/common/consul" + "gitea.com/red-future/common/http" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/gconv" + "strings" +) + +type swaggerDoc struct { + OpenAPI string `json:"openapi"` + Info map[string]interface{} `json:"info"` + Paths map[string]interface{} `json:"paths"` + Components map[string]interface{} `json:"components"` + // 可以根据需要定义更多字段 +} + +func updateRefsRecursively(data interface{}, serviceName string) { + switch v := data.(type) { + case map[string]interface{}: + for key, value := range v { + if key == "$ref" { + refStr, ok := value.(string) + if ok { + // 处理 OpenAPI 3.0+ 的引用 + if strings.HasPrefix(refStr, "#/components/schemas/") { + originalSchemaName := strings.TrimPrefix(refStr, "#/components/schemas/") + newRef := fmt.Sprintf("#/components/schemas/%s_%s", serviceName, originalSchemaName) + v[key] = newRef + } + // 处理 Swagger 2.0 的引用 + if strings.HasPrefix(refStr, "#/definitions/") { + originalSchemaName := strings.TrimPrefix(refStr, "#/definitions/") + newRef := fmt.Sprintf("#/components/schemas/%s_%s", serviceName, originalSchemaName) + v[key] = newRef + } + } + } else { + updateRefsRecursively(value, serviceName) + } + } + case []interface{}: + for _, item := range v { + updateRefsRecursively(item, serviceName) + } + } +} +func init() { + http.Httpserver.SetSwaggerPath("/docs") //api文档访问路径 + http.Httpserver.SetSwaggerUITemplate(` + + + + + + + SwaggerUI + + + +
+ + + + + `) + http.Httpserver.BindHandler("/swagger", func(r *ghttp.Request) { + services := g.Cfg().MustGet(r.GetCtx(), "consul.services").Slice() + aggregatedDoc := &swaggerDoc{ + OpenAPI: "3.0.0", // 假设统一为 3.0.0 版本 + Info: map[string]interface{}{ + "title": "Aggregated API Documentation", + "version": "1.0.0", + "description": "This document aggregates Swagger docs from all microservices.", + }, + Paths: make(map[string]interface{}), + Components: make(map[string]interface{}), + } + for _, v := range services { + serviceName := gconv.String(gconv.Map(v)["name"]) + instanceAddr, err := consul.GetInstanceAddr(r.GetCtx(), serviceName) + if err != nil || instanceAddr == "" { + continue + } + targetURL := fmt.Sprintf("%s://%s/api.json", "http", instanceAddr) // 假设文档地址是 /swagger/doc.json + var svcDoc swaggerDoc + result := g.Client().GetContent(r.GetCtx(), targetURL) + gconv.Struct(result, &svcDoc) + if err != nil { + continue + } + // 1. 合并 Paths,为每个路径添加服务名前缀 + for path, pathItem := range svcDoc.Paths { + prefixedPath := fmt.Sprintf("/%s%s", serviceName, path) + updateRefsRecursively(pathItem, serviceName) + aggregatedDoc.Paths[prefixedPath] = pathItem + } + + // 2. 合并 Components (Schemas, Responses, etc.) + // 注意:这里需要处理命名冲突,一个简单的方式是在组件名前加上服务名前缀 + if svcDoc.Components != nil { + for compType, compDefs := range svcDoc.Components { + compDefsMap, ok := compDefs.(map[string]interface{}) + if !ok { + continue + } + if _, exists := aggregatedDoc.Components[compType]; !exists { + aggregatedDoc.Components[compType] = make(map[string]interface{}) + } + aggregatedCompDefsMap, _ := aggregatedDoc.Components[compType].(map[string]interface{}) + for compName, compValue := range compDefsMap { + prefixedCompName := fmt.Sprintf("%s_%s", serviceName, compName) + aggregatedCompDefsMap[prefixedCompName] = compValue + } + } + } + } + r.Response.WriteExit(aggregatedDoc) + }) +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..4f741c2 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,383 @@ +package utils + +import ( + "context" + "encoding/json" + "fmt" + "github.com/gogf/gf/v2/os/gtime" + "net" + "reflect" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + "github.com/tiger1103/gfast-token/gftoken" +) + +// ValidStructPtr 验证是否为结构体指针 +func ValidStructPtr(req any) (err error) { + //验证请求参数必须为指针 + var ( + reflectValue reflect.Value + reflectKind reflect.Kind + ) + if v, ok := req.(reflect.Value); ok { + reflectValue = v + } else { + reflectValue = reflect.ValueOf(req) + } + + reflectKind = reflectValue.Kind() + if reflectKind != reflect.Ptr { + err = gerror.NewCode(gcode.CodeInvalidParameter, `the parameter "req" for function Find should type of *struct/*[]struct`) + } + return +} + +// GetMonthToday 获取N个月前的某日 +func GetMonthToday(t time.Time, month int) time.Time { + // today + fmt.Printf("today: [%s]\n", t) + // 判断天数范围 小于等于28天的计算,覆盖大多数情况 + if t.Day() <= 28 { + return t.AddDate(0, -month, 0) + } + // 月份的天数数组 + monthDay := [13]int{0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + // 计算目标所在日期 + target := t.AddDate(0, 0, 1-t.Day()).AddDate(0, -month, 0) + // 计算当月最大天数 + targetDay := monthDay[target.Month()] + // 计算闰年 + if target.Month() == time.February && (target.Year()%400 == 0 || (target.Year()%100 != 0 && target.Year()%4 == 0)) { + targetDay++ + } + if t.Day() > targetDay { + return target.AddDate(0, 0, targetDay-1) + } + return target.AddDate(0, 0, t.Day()-1) +} + +func GetUserInfo(ctx context.Context) (user beans.User, err error) { + // 检查context是否已取消 + select { + case <-ctx.Done(): + return user, ctx.Err() + default: + } + + if !g.IsNil(ctx.Value("id")) || !g.IsNil(ctx.Value("userName")) || !g.IsNil(ctx.Value("tenantId")) { + user.UserId = ctx.Value("id") + user.UserName = ctx.Value("userName") + user.TenantId = ctx.Value("tenantId") + } else { + redisAddr := g.Cfg().MustGet(ctx, "redis.default.address").String() + gft := gftoken.NewGfToken( + gftoken.WithCacheKey("gfToken:"), + gftoken.WithTimeout(20), + gftoken.WithMaxRefresh(10), + gftoken.WithMultiLogin(true), + //gftoken.WithExcludePaths(g.SliceStr{"/excludeDemo"}), + gftoken.WithGRedisConfig(&gredis.Config{ + Address: redisAddr, + Db: 1, + })) + var data *gftoken.CustomClaims + + if !g.IsNil(ctx.Value("token")) { + var tokenData *gftoken.TokenData + tokenData, _, err = gft.GetTokenData(ctx, ctx.Value("token").(string)) + if err != nil { + return user, gerror.Wrap(err, "token 解析失败") + } + var code int + if data, code = gft.IsNotExpired(tokenData.JwtToken); code != gftoken.JwtTokenOK { + return user, gerror.New("token jwt 解析失败") + } + } else if g.RequestFromCtx(ctx) != nil { + // 解析 token + data, err = gft.ParseToken(g.RequestFromCtx(ctx)) + if err != nil { + return user, gerror.Wrap(err, "token 解析失败") + } + } + + // 检查 data 是否为 nil + if data == nil { + return user, gerror.New("token 数据为空") + } + // 检查 data.Data 是否为 nil + if data.Data == nil { + g.Log().Errorf(ctx, "data.Data 为空") + return user, gerror.New("用户信息为空") + } + dataMap := gconv.Map(data.Data) + user.UserId = dataMap["id"] + user.UserName = dataMap["userName"] + user.TenantId = dataMap["tenantId"] + } + + if g.IsNil(user.UserId) && g.IsNil(user.UserName) && g.IsNil(user.TenantId) { + return user, gerror.New("租户信息为空") + } + return +} + +func SetValue(ctx context.Context, result any, key string, value any) { + // 检查context是否已取消 + select { + case <-ctx.Done(): + return + default: + // 使用反射设置result的Total属性 + resultValue := reflect.ValueOf(result) + if resultValue.Kind() == reflect.Ptr { + resultValue = resultValue.Elem() + totalField := resultValue.FieldByName(key) + if totalField.IsValid() && totalField.CanSet() { + totalField.Set(reflect.ValueOf(value)) + } + } + return + } +} +func Struts(ctx context.Context, pointer any, mapping ...map[string]string) { + // 检查context是否已取消 + select { + case <-ctx.Done(): + return + default: + + return + } +} +func OrderMap(m map[string]interface{}) map[string]interface{} { + // 提取所有key + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + // 使用标准排序算法对key进行排序 + // 使用strings.Sort确保排序结果永远一致 + sort.Strings(keys) + + // 创建有序map + orderedMap := make(map[string]interface{}, len(m)) + for _, k := range keys { + orderedMap[k] = m[k] + } + + return orderedMap +} + +// ParseIntSlice 解析整数切片 - 通用字符串处理工具 +func ParseIntSlice(str string) []int { + parts := strings.Split(str, ",") + result := make([]int, 0, len(parts)) + for _, part := range parts { + if val, err := strconv.Atoi(strings.TrimSpace(part)); err == nil { + result = append(result, val) + } + } + return result +} + +// ParseStrings 解析字符串切片 - 通用字符串处理工具 +func ParseStrings(str string) []string { + if str == "" { + return nil + } + parts := strings.Split(str, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// FilterServiceNames 过滤服务名 - 通用映射处理工具 +func FilterServiceNames(services map[string]interface{}, excludeKeys ...string) []string { + excludeMap := make(map[string]bool) + for _, key := range excludeKeys { + excludeMap[key] = true + } + result := make([]string, 0, len(services)) + for key := range services { + if !excludeMap[key] { + result = append(result, key) + } + } + return result +} + +// FormatUnixTime 格式化Unix时间戳 - 通用时间处理工具 +func FormatUnixTime(timestamp int64) string { + if timestamp <= 0 { + return "" + } + return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") +} + +// ParseDurationWithDefault 解析持续时间,失败时使用默认值 - 通用时间处理工具 +func ParseDurationWithDefault(ctx context.Context, durationStr, defaultStr, fieldName string) (time.Duration, string) { + // 检查context是否已取消 + select { + case <-ctx.Done(): + return 0, "" + default: + } + + durationParsed, err := time.ParseDuration(durationStr) + if err != nil { + // 这里不能直接使用g.Log(),因为这是utils包,没有直接的日志访问 + // 调用方应该处理日志 + // g.Log().Warningf(ctx, "解析%s失败: %s, 使用默认值 %s, error: %v", fieldName, durationStr, defaultStr, err) + durationParsed, _ = time.ParseDuration(defaultStr) + return durationParsed, defaultStr + } + return durationParsed, durationStr +} + +// AtomicUpdateMin 原子更新最小值 - 通用数值处理工具 +func AtomicUpdateMin(minValue *atomic.Int64, newValue int64) { + for { + currentMin := minValue.Load() + if newValue >= currentMin { + break + } + if minValue.CompareAndSwap(currentMin, newValue) { + break + } + } +} + +// AtomicUpdateMax 原子更新最大值 - 通用数值处理工具 +func AtomicUpdateMax(maxValue *atomic.Int64, newValue int64) { + for { + currentMax := maxValue.Load() + if newValue <= currentMax { + break + } + if maxValue.CompareAndSwap(currentMax, newValue) { + break + } + } +} + +// ParseCIDRs 解析CIDR列表 - 通用网络处理工具 +func ParseCIDRs(strs []string) ([]*net.IPNet, error) { + nets := make([]*net.IPNet, 0, len(strs)) + for _, s := range strs { + if s == "*" { + if _, ipv4Net, err := net.ParseCIDR("0.0.0.0/0"); err == nil { + nets = append(nets, ipv4Net) + } + if _, ipv6Net, err := net.ParseCIDR("::/0"); err == nil { + nets = append(nets, ipv6Net) + } + continue + } + if _, ipNet, err := net.ParseCIDR(s); err == nil { + nets = append(nets, ipNet) + } + } + return nets, nil +} + +// UrlDecode 简单的URL解码 - 通用编码解码工具 +func UrlDecode(s string) string { + result := make([]byte, 0, len(s)) + + for i := 0; i < len(s); i++ { + if s[i] == '%' && i+2 < len(s) { + if high := HexDigit(s[i+1]); high != 0xFF { + if low := HexDigit(s[i+2]); low != 0xFF { + result = append(result, (high<<4)|low) + i += 2 + continue + } + } + } + result = append(result, s[i]) + } + + return string(result) +} + +// HexDigit 十六进制字符转数字 - 通用编码解码工具 +func HexDigit(c byte) byte { + switch { + case '0' <= c && c <= '9': + return c - '0' + case 'a' <= c && c <= 'f': + return c - 'a' + 10 + case 'A' <= c && c <= 'F': + return c - 'A' + 10 + default: + return 0xFF + } +} + +func Struct(params any, pointer any) error { + b, err := json.Marshal(params) + if err != nil { + return err + } + err = json.Unmarshal(b, pointer) + if err != nil { + return err + } + return nil +} + +// IncrSequence 自增序列号 - 通用序列号工具 +func IncrSequence(ctx context.Context, prefix string, incrLen int, seqSep string) (string, error) { + // 1. 校验incrLen合法性(至少1位,最多10位,避免位数过大) + if incrLen < 1 || incrLen > 8 { + g.Log().Warningf(ctx, "自增数位数[%d]不合法,默认使用8位", incrLen) + incrLen = 8 // 兜底默认8位 + } + // 2. 获取当前时间,格式化为"年月日"(如20260226) + timeStr := gtime.Now().Format("Ymd") + // 3. 拼接Redis的key(seq:前缀:时间字符串),确保每天一个独立的自增序列 + redisKey := fmt.Sprintf("%s:%s:%s", "SEQ", prefix, timeStr) + // 4. 调用Redis的Incr做自增,失败则生成兜底序列号 + seqNum, err := g.Redis().Incr(ctx, redisKey) + if err != nil { + return "", fmt.Errorf("redis自增失败: %w", err) + } + // 5. 自动清理过期key,定时任务 + + // 6. 校验自增数是否超过incrLen位上限,超过则取模(避免格式混乱) + maxSeq := intPow10(incrLen) - 1 + if seqNum > int64(maxSeq) { + return "", fmt.Errorf("自增数[%d]超过%d位上限[%d]", seqNum, incrLen, maxSeq) + } + // 7. 格式化自增数为指定位数,不足补零 + seqFormat := fmt.Sprintf("%%0%dd", incrLen) // 动态生成格式化字符串 + seqStr := fmt.Sprintf(seqFormat, seqNum) + // 8. 拼接最终序列号 + finalSeq := fmt.Sprintf("%s%s%s%s%s", prefix, seqSep, timeStr, seqSep, seqStr) + + return finalSeq, nil +} + +// intPow10 计算10的n次方(辅助函数,如intPow10(6)=1000000) +func intPow10(n int) int { + result := 1 + for i := 0; i < n; i++ { + result *= 10 + } + return result +}