mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Extend the local plugin system's host<->plugin contract with generic, capability-neutral facilities, so transport plugins can be built on top without embedding plugin-specific logic in the host. Host services (plugin dials a broker-provided HostService gRPC): - per-plugin namespaced KV store (key/value size and TTL bounded) - account directory: ListAccounts + ResolveOutboundIdentity, restricted to the accounts a plugin's declared capability already covers - InitHostServices lifecycle hook; plugins that don't implement it run unchanged and are simply offered no host services Read-only runtime status channel: - HealthResponse gains an opaque, plugin-defined status_json produced passively (no config apply, no upstream call, no side effects) - GET /admin/plugins/:id/status (ungated) and the plugin.status UI-bridge verb map to Health, so a status UI can poll live state without a step-up prompt or a "test" side effect - config.test no longer force-shows a host success toast; the plugin UI owns result presentation, so plugins that read status do not spam toasts Document the UI-bridge verb taxonomy (read vs mutate/test gating) and the status contract under pkg/pluginapi.
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// pluginKVKeyPrefix 是所有插件通用键值存储在 Redis 中的根前缀。版本段 v1 便于未来
|
|
// 变更内部键结构时平滑迁移。
|
|
const pluginKVKeyPrefix = "plugin:kv:v1:"
|
|
|
|
// pluginKVScanCount 是 SCAN 每轮的 COUNT 提示,兼顾单轮开销与往返次数。
|
|
const pluginKVScanCount = 200
|
|
|
|
type pluginKVStore struct {
|
|
rdb *redis.Client
|
|
}
|
|
|
|
// NewPluginKVStore 返回 Redis 支撑的通用插件键值存储。使用 Redis 而非进程内存,使得
|
|
// 插件状态可跨副本、跨重启共享,这正是有状态插件所需的通用能力。
|
|
func NewPluginKVStore(rdb *redis.Client) service.PluginKVStore {
|
|
return &pluginKVStore{rdb: rdb}
|
|
}
|
|
|
|
// namespacePrefix 拼出某插件某命名空间的内部键前缀。pluginKey / namespace 均已由宿主
|
|
// 服务端按 [A-Za-z0-9._-] 校验,不含 ':' 或 glob 元字符,键结构因此无歧义。
|
|
func (s *pluginKVStore) namespacePrefix(pluginKey, namespace string) string {
|
|
return pluginKVKeyPrefix + pluginKey + ":" + namespace + ":"
|
|
}
|
|
|
|
func (s *pluginKVStore) fullKey(pluginKey, namespace, key string) string {
|
|
return s.namespacePrefix(pluginKey, namespace) + key
|
|
}
|
|
|
|
func (s *pluginKVStore) Get(ctx context.Context, pluginKey, namespace, key string) ([]byte, bool, error) {
|
|
value, err := s.rdb.Get(ctx, s.fullKey(pluginKey, namespace, key)).Bytes()
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("get plugin kv: %w", err)
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
func (s *pluginKVStore) Set(ctx context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error {
|
|
// go-redis 将 ttl==0 视为不过期,正好对应“持久保存”语义。
|
|
if err := s.rdb.Set(ctx, s.fullKey(pluginKey, namespace, key), value, ttl).Err(); err != nil {
|
|
return fmt.Errorf("set plugin kv: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *pluginKVStore) Delete(ctx context.Context, pluginKey, namespace, key string) error {
|
|
if err := s.rdb.Del(ctx, s.fullKey(pluginKey, namespace, key)).Err(); err != nil {
|
|
return fmt.Errorf("delete plugin kv: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *pluginKVStore) List(ctx context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error) {
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
internalPrefix := s.namespacePrefix(pluginKey, namespace)
|
|
// keyPrefix 已按 [A-Za-z0-9._-] 校验,不含 glob 元字符,可安全拼接进 MATCH。
|
|
pattern := internalPrefix + keyPrefix + "*"
|
|
seen := make(map[string]struct{}, limit)
|
|
keys := make([]string, 0, limit)
|
|
var cursor uint64
|
|
for {
|
|
batch, next, err := s.rdb.Scan(ctx, cursor, pattern, pluginKVScanCount).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan plugin kv: %w", err)
|
|
}
|
|
for _, full := range batch {
|
|
// SCAN 可能返回重复键,去重后再裁剪前缀返回面向插件的键名。
|
|
if _, ok := seen[full]; ok {
|
|
continue
|
|
}
|
|
seen[full] = struct{}{}
|
|
keys = append(keys, strings.TrimPrefix(full, internalPrefix))
|
|
if len(keys) >= limit {
|
|
return keys, nil
|
|
}
|
|
}
|
|
cursor = next
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|