|
|
@@ -12,24 +12,110 @@ import (
|
|
|
"fwh-toolbox-server/cmd/mcp/tools/helper"
|
|
|
)
|
|
|
|
|
|
+// Redis 只读工具集。
|
|
|
+// 说明:本工具集仅提供只读命令,不包含任何写操作。数据均位于业务约定的 Redis DB 12
|
|
|
+//(业务缓存 + asynq 任务队列),由 MCP 进程启动时的 cache.Init 固定连接。
|
|
|
+
|
|
|
func registerRedisTools(s *server.MCPServer) {
|
|
|
s.AddTool(
|
|
|
mcp.NewTool("data_redis_get",
|
|
|
- mcp.WithDescription("读取 Redis 中指定 key 的值(只读,仅支持 string 和 hash 类型)"),
|
|
|
+ mcp.WithDescription("读取 Redis 中指定 key 的值(只读)。支持 string/hash/list/set/zset;type=auto 时自动探测类型。"),
|
|
|
mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
|
|
|
- mcp.WithString("type", mcp.Description("key 的数据类型:string(默认)、hash、list、set")),
|
|
|
- mcp.WithNumber("limit", mcp.Description("list/set 类型返回的最大元素数,默认100,最大1000。string/hash 忽略此参数。")),
|
|
|
+ mcp.WithString("type", mcp.Description("数据类型:auto(默认,自动探测)、string、hash、list、set、zset")),
|
|
|
+ mcp.WithNumber("limit", mcp.Description("list/set/zset 返回的最大元素数,默认100,最大1000。string/hash 忽略。")),
|
|
|
),
|
|
|
handleRedisGet,
|
|
|
)
|
|
|
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_mget",
|
|
|
+ mcp.WithDescription("批量读取多个 string key 的值(只读)。"),
|
|
|
+ mcp.WithString("keys", mcp.Required(), mcp.Description("多个 key,用英文逗号分隔")),
|
|
|
+ ),
|
|
|
+ handleRedisMGet,
|
|
|
+ )
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_hget",
|
|
|
+ mcp.WithDescription("读取 hash 中指定 field 的值(只读)。"),
|
|
|
+ mcp.WithString("key", mcp.Required(), mcp.Description("Redis hash key")),
|
|
|
+ mcp.WithString("field", mcp.Required(), mcp.Description("hash field 名")),
|
|
|
+ ),
|
|
|
+ handleRedisHGet,
|
|
|
+ )
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_type",
|
|
|
+ mcp.WithDescription("查看 key 的类型及规模概览(只读):类型、TTL、以及对应类型的长度/元素数。"),
|
|
|
+ mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
|
|
|
+ ),
|
|
|
+ handleRedisType,
|
|
|
+ )
|
|
|
+
|
|
|
s.AddTool(
|
|
|
mcp.NewTool("data_redis_exists",
|
|
|
- mcp.WithDescription("检查 Redis key 是否存在,并返回 TTL 信息"),
|
|
|
+ mcp.WithDescription("检查 Redis key 是否存在,并返回 TTL 信息(只读)。key 支持精确匹配。"),
|
|
|
mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
|
|
|
),
|
|
|
handleRedisExists,
|
|
|
)
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_ttl",
|
|
|
+ mcp.WithDescription("查看 key 的存活时间 TTL(只读)。"),
|
|
|
+ mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
|
|
|
+ ),
|
|
|
+ handleRedisTTL,
|
|
|
+ )
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_scan",
|
|
|
+ mcp.WithDescription("游标遍历 key(只读,非阻塞,推荐用于扫描)。返回本批 keys 与下一个游标(cursor=0 表示结束)。"),
|
|
|
+ mcp.WithString("match", mcp.Description("匹配模式,如 user:* ;为空则匹配全部")),
|
|
|
+ mcp.WithNumber("cursor", mcp.Description("起始游标,首次传 0,后续传上次返回的 next_cursor")),
|
|
|
+ mcp.WithNumber("count", mcp.Description("每批返回数量提示,默认100,最大1000")),
|
|
|
+ ),
|
|
|
+ handleRedisScan,
|
|
|
+ )
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_keys",
|
|
|
+ mcp.WithDescription("按 pattern 返回匹配的 key(只读)。注意:KEYS 为阻塞命令,大库慎用,建议优先用 data_redis_scan。"),
|
|
|
+ mcp.WithString("pattern", mcp.Required(), mcp.Description("匹配模式,如 user:* 或 * ")),
|
|
|
+ mcp.WithNumber("limit", mcp.Description("返回的最大 key 数,默认1000,最大10000")),
|
|
|
+ ),
|
|
|
+ handleRedisKeys,
|
|
|
+ )
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_stream_range",
|
|
|
+ mcp.WithDescription("读取 Stream 指定范围内的消息(只读)。"),
|
|
|
+ mcp.WithString("key", mcp.Required(), mcp.Description("Stream key")),
|
|
|
+ mcp.WithString("start", mcp.Description("起始 ID,默认 '-'(最早)")),
|
|
|
+ mcp.WithString("stop", mcp.Description("结束 ID,默认 '+'(最新)")),
|
|
|
+ mcp.WithNumber("count", mcp.Description("返回的最大消息数,默认100,最大1000")),
|
|
|
+ ),
|
|
|
+ handleRedisStreamRange,
|
|
|
+ )
|
|
|
+
|
|
|
+ s.AddTool(
|
|
|
+ mcp.NewTool("data_redis_stream_info",
|
|
|
+ mcp.WithDescription("查看 Stream 元信息与消费者组信息(只读):长度、首末消息、消费者组列表。"),
|
|
|
+ mcp.WithString("key", mcp.Required(), mcp.Description("Stream key")),
|
|
|
+ ),
|
|
|
+ handleRedisStreamInfo,
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// clampLimit 将 limit 限制在 [1, max],非法值回退到 def。
|
|
|
+func clampLimit(v, def, max int) int {
|
|
|
+ if v < 1 {
|
|
|
+ return def
|
|
|
+ }
|
|
|
+ if v > max {
|
|
|
+ return max
|
|
|
+ }
|
|
|
+ return v
|
|
|
}
|
|
|
|
|
|
func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
@@ -38,15 +124,19 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|
|
return helper.ParamError("key"), nil
|
|
|
}
|
|
|
|
|
|
- keyType := req.GetString("type", "string")
|
|
|
+ keyType := req.GetString("type", "auto")
|
|
|
+ limit := clampLimit(int(req.GetFloat("limit", 100)), 100, 1000)
|
|
|
|
|
|
- // limit 仅对 list/set 生效,默认100,上限1000
|
|
|
- limit := int(req.GetFloat("limit", 100))
|
|
|
- if limit < 1 {
|
|
|
- limit = 100
|
|
|
- }
|
|
|
- if limit > 1000 {
|
|
|
- limit = 1000
|
|
|
+ // auto:先探测类型
|
|
|
+ if keyType == "auto" {
|
|
|
+ t, terr := redisx.Type(ctx, key)
|
|
|
+ if terr != nil {
|
|
|
+ return helper.ServiceError("探测Redis key类型", terr), nil
|
|
|
+ }
|
|
|
+ if t == "none" {
|
|
|
+ return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
|
|
|
+ }
|
|
|
+ keyType = t
|
|
|
}
|
|
|
|
|
|
switch keyType {
|
|
|
@@ -56,9 +146,7 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|
|
return helper.EmptyResult(fmt.Sprintf("key 不存在或读取失败: %s (%v)", key, err)), nil
|
|
|
}
|
|
|
return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
- "key": key,
|
|
|
- "type": "string",
|
|
|
- "value": val,
|
|
|
+ "key": key, "type": "string", "value": val,
|
|
|
}), nil
|
|
|
|
|
|
case "hash":
|
|
|
@@ -70,9 +158,7 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|
|
return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
|
|
|
}
|
|
|
return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
- "key": key,
|
|
|
- "type": "hash",
|
|
|
- "value": val,
|
|
|
+ "key": key, "type": "hash", "count": len(val), "value": val,
|
|
|
}), nil
|
|
|
|
|
|
case "list":
|
|
|
@@ -84,12 +170,8 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|
|
return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
|
|
|
}
|
|
|
return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
- "key": key,
|
|
|
- "type": "list",
|
|
|
- "count": len(val),
|
|
|
- "limit": limit,
|
|
|
- "truncated": len(val) == limit,
|
|
|
- "value": val,
|
|
|
+ "key": key, "type": "list", "count": len(val),
|
|
|
+ "limit": limit, "truncated": len(val) == limit, "value": val,
|
|
|
}), nil
|
|
|
|
|
|
case "set":
|
|
|
@@ -107,36 +189,139 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|
|
truncated = true
|
|
|
}
|
|
|
return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
- "key": key,
|
|
|
- "type": "set",
|
|
|
- "total": total,
|
|
|
- "count": len(val),
|
|
|
- "limit": limit,
|
|
|
- "truncated": truncated,
|
|
|
- "value": val,
|
|
|
+ "key": key, "type": "set", "total": total, "count": len(val),
|
|
|
+ "limit": limit, "truncated": truncated, "value": val,
|
|
|
+ }), nil
|
|
|
+
|
|
|
+ case "zset":
|
|
|
+ val, err := redisx.ZRangeWithScores(ctx, key, 0, int64(limit-1))
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("读取Redis zset", err), nil
|
|
|
+ }
|
|
|
+ if len(val) == 0 {
|
|
|
+ return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
|
|
|
+ }
|
|
|
+ members := make([]map[string]any, 0, len(val))
|
|
|
+ for _, z := range val {
|
|
|
+ members = append(members, map[string]any{"member": z.Member, "score": z.Score})
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "key": key, "type": "zset", "count": len(members),
|
|
|
+ "limit": limit, "truncated": len(members) == limit, "value": members,
|
|
|
}), nil
|
|
|
|
|
|
default:
|
|
|
- return helper.ParamInvalidError("type", "可选值: string, hash, list, set"), nil
|
|
|
+ return helper.ParamInvalidError("type", "可选值: auto, string, hash, list, set, zset"), nil
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func handleRedisExists(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+func handleRedisMGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ raw, err := req.RequireString("keys")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("keys"), nil
|
|
|
+ }
|
|
|
+ keys := make([]string, 0)
|
|
|
+ for _, k := range strings.Split(raw, ",") {
|
|
|
+ if s := strings.TrimSpace(k); s != "" {
|
|
|
+ keys = append(keys, s)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if len(keys) == 0 {
|
|
|
+ return helper.ParamError("keys"), nil
|
|
|
+ }
|
|
|
+ vals, err := redisx.MGet(ctx, keys...)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("批量读取Redis string", err), nil
|
|
|
+ }
|
|
|
+ result := make(map[string]any, len(keys))
|
|
|
+ for i, k := range keys {
|
|
|
+ if i < len(vals) {
|
|
|
+ result[k] = vals[i] // nil 表示 key 不存在
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "type": "mget", "count": len(keys), "value": result,
|
|
|
+ }), nil
|
|
|
+}
|
|
|
+
|
|
|
+func handleRedisHGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
key, err := req.RequireString("key")
|
|
|
if err != nil {
|
|
|
return helper.ParamError("key"), nil
|
|
|
}
|
|
|
+ field, err := req.RequireString("field")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("field"), nil
|
|
|
+ }
|
|
|
+ val, err := redisx.HGet(ctx, key, field)
|
|
|
+ if err != nil {
|
|
|
+ return helper.EmptyResult(fmt.Sprintf("field 不存在或读取失败: %s.%s (%v)", key, field, err)), nil
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "key": key, "type": "hash", "field": field, "value": val,
|
|
|
+ }), nil
|
|
|
+}
|
|
|
|
|
|
- // 安全检查:不允许通配符
|
|
|
- if strings.Contains(key, "*") {
|
|
|
- return helper.ParamInvalidError("key", "不支持通配符,请提供精确 key"), nil
|
|
|
+func handleRedisType(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ key, err := req.RequireString("key")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("key"), nil
|
|
|
+ }
|
|
|
+ t, err := redisx.Type(ctx, key)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("获取Redis key类型", err), nil
|
|
|
+ }
|
|
|
+ if t == "none" {
|
|
|
+ return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
|
|
|
+ }
|
|
|
+
|
|
|
+ out := map[string]any{"key": key, "type": t}
|
|
|
+
|
|
|
+ // TTL
|
|
|
+ if ttl, e := redisx.TTL(ctx, key); e == nil {
|
|
|
+ out["ttl"] = ttlString(ttl.Seconds(), ttl == -1, ttl == -2)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 各类型规模概览
|
|
|
+ switch t {
|
|
|
+ case "string":
|
|
|
+ if n, e := redisx.StrLen(ctx, key); e == nil {
|
|
|
+ out["length"] = n
|
|
|
+ }
|
|
|
+ case "hash":
|
|
|
+ if n, e := redisx.HLen(ctx, key); e == nil {
|
|
|
+ out["fields"] = n
|
|
|
+ }
|
|
|
+ case "list":
|
|
|
+ if n, e := redisx.LLen(ctx, key); e == nil {
|
|
|
+ out["length"] = n
|
|
|
+ }
|
|
|
+ case "set":
|
|
|
+ if n, e := redisx.SCard(ctx, key); e == nil {
|
|
|
+ out["members"] = n
|
|
|
+ }
|
|
|
+ case "zset":
|
|
|
+ if n, e := redisx.ZCard(ctx, key); e == nil {
|
|
|
+ out["members"] = n
|
|
|
+ }
|
|
|
+ case "stream":
|
|
|
+ if n, e := redisx.XLen(ctx, key); e == nil {
|
|
|
+ out["entries"] = n
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(out), nil
|
|
|
+}
|
|
|
+
|
|
|
+func handleRedisExists(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ key, err := req.RequireString("key")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("key"), nil
|
|
|
}
|
|
|
|
|
|
exists, err := redisx.Exists(ctx, key)
|
|
|
if err != nil {
|
|
|
return helper.ServiceError("检查Redis key", err), nil
|
|
|
}
|
|
|
-
|
|
|
if !exists {
|
|
|
return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
|
|
|
}
|
|
|
@@ -145,17 +330,132 @@ func handleRedisExists(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|
|
if err != nil {
|
|
|
return helper.ServiceError("获取TTL", err), nil
|
|
|
}
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "key": key, "exists": true,
|
|
|
+ "ttl": ttlString(ttl.Seconds(), ttl == -1, ttl == -2),
|
|
|
+ }), nil
|
|
|
+}
|
|
|
+
|
|
|
+func handleRedisTTL(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ key, err := req.RequireString("key")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("key"), nil
|
|
|
+ }
|
|
|
+ ttl, err := redisx.TTL(ctx, key)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("获取TTL", err), nil
|
|
|
+ }
|
|
|
+ if ttl == -2 {
|
|
|
+ return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "key": key,
|
|
|
+ "ttl": ttlString(ttl.Seconds(), ttl == -1, ttl == -2),
|
|
|
+ "ttl_seconds": int64(ttl.Seconds()),
|
|
|
+ }), nil
|
|
|
+}
|
|
|
+
|
|
|
+func handleRedisScan(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ cursor := uint64(req.GetFloat("cursor", 0))
|
|
|
+ match := req.GetString("match", "")
|
|
|
+ count := int64(clampLimit(int(req.GetFloat("count", 100)), 100, 1000))
|
|
|
+
|
|
|
+ keys, next, err := redisx.Scan(ctx, cursor, match, count)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("SCAN 遍历", err), nil
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "type": "scan", "match": match, "count": len(keys),
|
|
|
+ "next_cursor": next, "done": next == 0, "keys": keys,
|
|
|
+ }), nil
|
|
|
+}
|
|
|
+
|
|
|
+func handleRedisKeys(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ pattern, err := req.RequireString("pattern")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("pattern"), nil
|
|
|
+ }
|
|
|
+ limit := clampLimit(int(req.GetFloat("limit", 1000)), 1000, 10000)
|
|
|
+
|
|
|
+ keys, err := redisx.Keys(ctx, pattern)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("KEYS 匹配", err), nil
|
|
|
+ }
|
|
|
+ total := len(keys)
|
|
|
+ truncated := false
|
|
|
+ if total > limit {
|
|
|
+ keys = keys[:limit]
|
|
|
+ truncated = true
|
|
|
+ }
|
|
|
+ return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
+ "type": "keys", "pattern": pattern, "total": total,
|
|
|
+ "count": len(keys), "limit": limit, "truncated": truncated, "keys": keys,
|
|
|
+ }), nil
|
|
|
+}
|
|
|
|
|
|
- ttlStr := "永不过期"
|
|
|
- if ttl > 0 {
|
|
|
- ttlStr = ttl.String()
|
|
|
- } else if ttl == -2 {
|
|
|
- ttlStr = "key 不存在"
|
|
|
+func handleRedisStreamRange(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ key, err := req.RequireString("key")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("key"), nil
|
|
|
}
|
|
|
+ start := req.GetString("start", "-")
|
|
|
+ stop := req.GetString("stop", "+")
|
|
|
+ count := int64(clampLimit(int(req.GetFloat("count", 100)), 100, 1000))
|
|
|
|
|
|
+ msgs, err := redisx.XRangeN(ctx, key, start, stop, count)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("读取Stream消息", err), nil
|
|
|
+ }
|
|
|
+ if len(msgs) == 0 {
|
|
|
+ return helper.EmptyResult(fmt.Sprintf("Stream 无消息或不存在: %s", key)), nil
|
|
|
+ }
|
|
|
+ items := make([]map[string]any, 0, len(msgs))
|
|
|
+ for _, m := range msgs {
|
|
|
+ items = append(items, map[string]any{"id": m.ID, "values": m.Values})
|
|
|
+ }
|
|
|
return helper.JSONResultOrFileAuto(map[string]any{
|
|
|
- "key": key,
|
|
|
- "exists": true,
|
|
|
- "ttl": ttlStr,
|
|
|
+ "key": key, "type": "stream", "count": len(items),
|
|
|
+ "limit": count, "truncated": int64(len(items)) == count, "value": items,
|
|
|
}), nil
|
|
|
}
|
|
|
+
|
|
|
+func handleRedisStreamInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
|
+ key, err := req.RequireString("key")
|
|
|
+ if err != nil {
|
|
|
+ return helper.ParamError("key"), nil
|
|
|
+ }
|
|
|
+
|
|
|
+ xlen, err := redisx.XLen(ctx, key)
|
|
|
+ if err != nil {
|
|
|
+ return helper.ServiceError("获取Stream长度", err), nil
|
|
|
+ }
|
|
|
+
|
|
|
+ out := map[string]any{"key": key, "type": "stream", "length": xlen}
|
|
|
+
|
|
|
+ if groups, e := redisx.XInfoGroups(ctx, key); e == nil {
|
|
|
+ gs := make([]map[string]any, 0, len(groups))
|
|
|
+ for _, g := range groups {
|
|
|
+ gs = append(gs, map[string]any{
|
|
|
+ "name": g.Name,
|
|
|
+ "consumers": g.Consumers,
|
|
|
+ "pending": g.Pending,
|
|
|
+ "last_delivered_id": g.LastDeliveredID,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ out["groups"] = gs
|
|
|
+ }
|
|
|
+
|
|
|
+ return helper.JSONResultOrFileAuto(out), nil
|
|
|
+}
|
|
|
+
|
|
|
+// ttlString 将 TTL 秒数格式化为可读字符串。
|
|
|
+// persist 表示无过期时间(-1),missing 表示 key 不存在(-2)。
|
|
|
+func ttlString(seconds float64, persist, missing bool) string {
|
|
|
+ if missing {
|
|
|
+ return "key 不存在"
|
|
|
+ }
|
|
|
+ if persist || seconds < 0 {
|
|
|
+ return "永不过期"
|
|
|
+ }
|
|
|
+ return fmt.Sprintf("%ds", int64(seconds))
|
|
|
+}
|