diff --git a/shortcuts/apps/apps_cache_clear.go b/shortcuts/apps/apps_cache_clear.go new file mode 100644 index 0000000000..602eb13750 --- /dev/null +++ b/shortcuts/apps/apps_cache_clear.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsCacheClear clears all cache entries for the app in the given environment. +// +// POST /apps/{app_id}/cache/clear,body {env}。清空当前应用指定环境下全部缓存,用于无法定位 +// 具体 key 的快速恢复;影响面大,定 high-risk-write(框架自动注入 --yes 确认)。 +var AppsCacheClear = common.Shortcut{ + Service: appsService, + Command: "+cache-clear", + Description: "Clear all cache entries for the app in the given environment", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +cache-clear --app-id --environment dev --yes", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + cacheEnvFlag(), + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appCacheClearPath(appID)). + Desc("Clear all cache entries for the app in the given environment"). + Body(dbEnvParams(rctx, map[string]interface{}{})) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{})) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := map[string]interface{}{ + "environment": resolvedEnv(data, rctx), + "deleted_key_count": cacheInt(data["deleted_key_count"]), + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderCacheClearPretty(w, out) + }) + return nil + }, +} + +// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。 +func renderCacheClearPretty(w io.Writer, out map[string]interface{}) { + n := int64(0) + if f, ok := numericAsFloat(out["deleted_key_count"]); ok { + n = int64(f) + } + fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment")) +} diff --git a/shortcuts/apps/apps_cache_delete.go b/shortcuts/apps/apps_cache_delete.go new file mode 100644 index 0000000000..572dfc4d11 --- /dev/null +++ b/shortcuts/apps/apps_cache_delete.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsCacheDelete deletes a single business cache key (idempotent). +// +// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建, +// 故定 write(非 high-risk-write、不需 --yes)。目标不存在按幂等成功处理(deleted_key_count=0)。 +var AppsCacheDelete = common.Shortcut{ + Service: appsService, + Command: "+cache-delete", + Description: "Delete a single business cache key (idempotent)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +cache-delete --app-id --environment dev --key ", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "key", Desc: "business cache key", Required: true}, + cacheEnvFlag(), + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + DELETE(appCachePath(appID)). + Desc("Delete a Miaoda app runtime cache key"). + Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")})) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + key := rctx.Str("key") + data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := map[string]interface{}{ + "key": key, + "environment": resolvedEnv(data, rctx), + "deleted_key_count": cacheInt(data["deleted_key_count"]), + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderCacheDeletePretty(w, out) + }) + return nil + }, +} + +// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。 +func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) { + key := common.GetString(out, "key") + if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 { + fmt.Fprintf(w, "✓ cache deleted: %s\n", key) + return + } + fmt.Fprintf(w, "✓ cache already absent: %s\n", key) +} diff --git a/shortcuts/apps/apps_cache_get.go b/shortcuts/apps/apps_cache_get.go new file mode 100644 index 0000000000..73dd26233c --- /dev/null +++ b/shortcuts/apps/apps_cache_get.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsCacheGet reads a single business cache key's value + metadata. +// +// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json +// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI +// 按 value 字节长度算出(端点不返回);未命中(exists=false)时不带 value,ttl_ms/value_size_bytes 为 null。 +var AppsCacheGet = common.Shortcut{ + Service: appsService, + Command: "+cache-get", + Description: "Get a business cache key's value and metadata", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +cache-get --app-id --key spotbonus:2026:winners:list:v1", + "Example: lark-cli apps +cache-get --app-id --environment online --key ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "key", Desc: "business cache key", Required: true}, + cacheEnvFlag(), + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appCachePath(appID)). + Desc("Get a Miaoda app runtime cache key"). + Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")})) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + key := rctx.Str("key") + data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := projectCacheGet(data, key, rctx) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderCacheGetPretty(w, out) + }) + return nil + }, +} + +// projectCacheGet 组装 cache-get 输出:key 回显、environment 取 resolved env、exists 直读; +// 命中时带 ttl_ms + value(原始串)+ value_size_bytes(CLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。 +func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} { + exists := cacheBool(data["exists"]) + out := map[string]interface{}{ + "key": key, + "environment": resolvedEnv(data, rctx), + "exists": exists, + } + if exists { + val := common.GetString(data, "value") + out["ttl_ms"] = cacheInt(data["ttl_ms"]) + out["value_size_bytes"] = len([]byte(val)) + out["value"] = val + } else { + out["ttl_ms"] = nil + out["value_size_bytes"] = nil + } + return out +} + +// renderCacheGetPretty 打元信息块(key/environment/exists,命中再加 ttl/value_size),命中时末尾展开 value。 +func renderCacheGetPretty(w io.Writer, out map[string]interface{}) { + exists, _ := out["exists"].(bool) + pairs := [][2]string{ + {"key", common.GetString(out, "key")}, + {"environment", common.GetString(out, "environment")}, + {"exists", fmt.Sprintf("%v", exists)}, + } + if exists { + pairs = append(pairs, + [2]string{"ttl", formatCacheTTL(out["ttl_ms"])}, + [2]string{"value_size", humanBytes(out["value_size_bytes"])}, + ) + } + renderKeyValuePairs(w, pairs) + if exists { + fmt.Fprintln(w, "value:") + printCacheValuePretty(w, common.GetString(out, "value")) + } +} diff --git a/shortcuts/apps/apps_cache_test.go b/shortcuts/apps/apps_cache_test.go new file mode 100644 index 0000000000..0d321d9437 --- /dev/null +++ b/shortcuts/apps/apps_cache_test.go @@ -0,0 +1,365 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const ( + cacheURL = "/open-apis/spark/v1/apps/app_x/cache" + cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear" +) + +// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串(value 不反序列化)。 +const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]` + +// ── cache-get ── + +// TestAppsCacheGet_HitJSON:命中时 json 默认——value 原样透传(不反序列化), +// value_size_bytes 由 CLI 按 value 字节长度算出,environment 取服务端 resolved env。 +func TestAppsCacheGet_HitJSON(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr, + }}, + }) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + d := parseEnvelopeData(t, stdout) + if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true { + t.Fatalf("get hit data=%v", d) + } + if v, _ := d["value"].(string); v != cacheValueStr { + t.Fatalf("value must be raw passthrough string, got %v", d["value"]) + } + if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) { + t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr)) + } + // ttl_ms 必须是 JSON number(透传服务端数字,不得变成字符串);JSON 解析后为 float64。 + if _, ok := d["ttl_ms"].(float64); !ok { + t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"]) + } +} + +// TestAppsCacheGet_HitPretty:pretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。 +func TestAppsCacheGet_HitPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr, + }}, + }) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"key", "environment", "exists", "value", "Alice"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } +} + +// TestAppsCacheGet_Miss:未命中——exists=false,无 value,ttl_ms / value_size_bytes 为 null。 +func TestAppsCacheGet_Miss(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "env": "online", "exists": false, + }}, + }) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + d := parseEnvelopeData(t, stdout) + if d["exists"] != false { + t.Fatalf("miss exists=%v", d["exists"]) + } + if _, ok := d["value"]; ok { + t.Fatalf("miss must not carry value: %v", d) + } + if d["ttl_ms"] != nil || d["value_size_bytes"] != nil { + t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d) + } +} + +// TestAppsCacheGet_ExistsAsString:服务端把 exists 返成字符串 "true" 时仍按命中处理 +// (cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。 +func TestAppsCacheGet_ExistsAsString(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr, + }}, + }) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + d := parseEnvelopeData(t, stdout) + if d["exists"] != true { + t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"]) + } + if v, _ := d["value"].(string); v != cacheValueStr { + t.Fatalf("命中应带 value, got %v", d["value"]) + } +} + +// TestAppsCacheGet_PrettyNonJSONFallback:pretty 下 value 不是合法 JSON 时降级原样输出 +// (safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。 +func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json", + }}, + }) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "hello-plain-not-json") { + t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String()) + } +} + +// TestAppsCacheGet_TTLAsStringNormalized:服务端把 ttl_ms 返成字符串 "272000" 时, +// 输出的 ttl_ms 必须归一成 JSON number(cacheInt),不得随 wire 形态漂移成字符串。 +func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr, + }}, + }) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + d := parseEnvelopeData(t, stdout) + f, ok := d["ttl_ms"].(float64) + if !ok { + t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"]) + } + if int(f) != 272000 { + t.Fatalf("ttl_ms = %v, want 272000", f) + } +} + +// TestAppsCacheDelete_CountAsStringNormalized:服务端把 deleted_key_count 返成字符串 "1" 时, +// 输出必须归一成 JSON number(cacheInt)。 +func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}}, + }) + if err := runAppsShortcut(t, AppsCacheDelete, + []string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + d := parseEnvelopeData(t, stdout) + if _, ok := d["deleted_key_count"].(float64); !ok { + t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"]) + } +} + +// TestAppsCacheGet_DryRunOmitsEnv:不传 --environment 时 dry-run query 不带 env(服务端自动选),但带 key。 +func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + a := firstDryRunAPI(t, stdout.String()) + if a.Method != "GET" || a.URL != cacheURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if _, ok := a.Params["env"]; ok { + t.Fatalf("no --environment → env must be omitted, params=%v", a.Params) + } + if a.Params["key"] != "k:1" { + t.Fatalf("key must be in query, params=%v", a.Params) + } +} + +// TestAppsCacheGet_DryRunWithEnv:显式 --environment dev → query 带 env=dev。 +func TestAppsCacheGet_DryRunWithEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + a := firstDryRunAPI(t, stdout.String()) + if a.Params["env"] != "dev" { + t.Fatalf("env must be dev, params=%v", a.Params) + } +} + +// TestAppsCacheGet_RequiresKey:缺 --key → 校验错。 +func TestAppsCacheGet_RequiresKey(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheGet, + []string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --key error") + } +} + +// ── cache-delete ── + +// TestAppsCacheDelete_Hit:删中命中的 key → deleted_key_count=1;pretty 打 "✓ cache deleted"。 +func TestAppsCacheDelete_Hit(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}}, + }) + if err := runAppsShortcut(t, AppsCacheDelete, + []string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "✓ cache deleted") { + t.Fatalf("pretty: %s", stdout.String()) + } +} + +// TestAppsCacheDelete_AbsentJSON:目标不存在 → 幂等成功,deleted_key_count=0,pretty 措辞区分。 +func TestAppsCacheDelete_AbsentJSON(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}}, + }) + if err := runAppsShortcut(t, AppsCacheDelete, + []string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + d := parseEnvelopeData(t, stdout) + if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" { + t.Fatalf("absent data=%v", d) + } +} + +// TestAppsCacheDelete_AbsentPretty:不存在 pretty 打 "✓ cache already absent"。 +func TestAppsCacheDelete_AbsentPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: cacheURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}}, + }) + if err := runAppsShortcut(t, AppsCacheDelete, + []string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "already absent") { + t.Fatalf("pretty: %s", stdout.String()) + } +} + +// TestAppsCacheDelete_DryRun:DELETE 方法、/cache 路由,query 带 key + env。 +func TestAppsCacheDelete_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheDelete, + []string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + a := firstDryRunAPI(t, stdout.String()) + if a.Method != "DELETE" || a.URL != cacheURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Params["key"] != "k:1" || a.Params["env"] != "dev" { + t.Fatalf("params=%v", a.Params) + } +} + +// ── cache-clear ── + +// TestAppsCacheClear_Success:清空成功 → deleted_key_count=128;pretty 打 "✓ cache cleared: 128 entries (dev)"。 +func TestAppsCacheClear_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: cacheClearURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}}, + }) + if err := runAppsShortcut(t, AppsCacheClear, + []string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") { + t.Fatalf("pretty: %s", stdout.String()) + } +} + +// TestAppsCacheClear_RequiresConfirmation:high-risk-write 无 --yes → 被确认门拦截。 +func TestAppsCacheClear_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheClear, + []string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// TestAppsCacheClear_DryRunBodyWithEnv:dry-run POST /cache/clear,body 带 env=dev。 +func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheClear, + []string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + a := firstDryRunAPI(t, stdout.String()) + if a.Method != "POST" || a.URL != cacheClearURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["env"] != "dev" { + t.Fatalf("body must carry env=dev, body=%v", a.Body) + } +} + +// TestAppsCacheClear_DryRunBodyOmitsEnv:不传 --environment → body 不带 env(服务端自动选)。 +func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCacheClear, + []string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + a := firstDryRunAPI(t, stdout.String()) + if _, ok := a.Body["env"]; ok { + t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body) + } +} + +// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项(method/url/params/body)。 +type dryRunAPIItem struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` +} + +func firstDryRunAPI(t *testing.T, s string) dryRunAPIItem { + t.Helper() + var env struct { + API []dryRunAPIItem `json:"api"` + } + if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 { + t.Fatalf("bad dry-run json: %v\n%s", err, s) + } + return env.API[0] +} diff --git a/shortcuts/apps/cache_common.go b/shortcuts/apps/cache_common.go new file mode 100644 index 0000000000..d5b0f6efe5 --- /dev/null +++ b/shortcuts/apps/cache_common.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// 应用运行时缓存(Cache)调试命令共享件:路由 + 环境 flag + 渲染。 +// +// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`,按运行环境(env→dbBranch)隔离: +// 环境 flag 用 cacheEnvFlag()(只 --environment,不带 db 家族的旧名 --env),env 值经 dbEnv 读、 +// 经 dbEnvParams 注入——get/delete 放 query,clear 放 body(省略即服务端自动选分支)。 + +// appCachePath 返回缓存单 key 读/删 URL:cache(GET 读、DELETE 删,靠方法区分)。 +func appCachePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appCacheClearPath 返回清空指定环境缓存 URL:cache/clear。 +func appCacheClearPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID)) +} + +// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env, +// 故只注册干净的 --environment(不带 db 家族那套隐藏 --env + 拒收逻辑)。 +// 省略即服务端按应用多环境状态自动选分支(多环境→dev,非多环境→online)。 +func cacheEnvFlag() common.Flag { + return common.Flag{ + Name: "environment", + Enum: []string{"dev", "online"}, + Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online", + } +} + +// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool, +// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中(hit→miss 翻转)。 +func cacheBool(v interface{}) bool { + switch x := v.(type) { + case bool: + return x + case string: + return strings.EqualFold(strings.TrimSpace(x), "true") + } + return false +} + +// cacheInt 把服务端下发的数值字段归一成 int64(无法解析→nil)。本仓惯例:数值可能以字符串下发 +// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移 +// (number ↔ string)。归一后输出类型恒定为数字或 null,消费方无需自己容忍字符串。 +func cacheInt(raw interface{}) interface{} { + if f, ok := numericAsFloat(raw); ok { + return int64(f) + } + return nil +} + +// resolvedEnv 取服务端回吐的 resolved env;缺失时兜底成请求侧 --environment(可能为空)。 +// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。 +func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string { + if env := common.GetString(data, "env"); env != "" { + return env + } + return dbEnv(rctx) +} + +// formatCacheTTL 把剩余 TTL(毫秒)格式化成 4m32s 这样的时长串;非数字返回 "—"。 +func formatCacheTTL(ms interface{}) string { + f, ok := numericAsFloat(ms) + if !ok { + return "—" + } + return (time.Duration(int64(f)) * time.Millisecond).String() +} + +// printCacheValuePretty 把 value 反序列化后缩进展开(pretty 口径);非 JSON 则原样打印。 +// 与「json 原样字符串、pretty 才反序列化」的设计一致。 +func printCacheValuePretty(w io.Writer, raw string) { + v := safeParseJSON(raw) + if s, ok := v.(string); ok { + fmt.Fprintln(w, s) + return + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintln(w, raw) + return + } + w.Write(b) + fmt.Fprintln(w) +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index b3ac97f5c1..3d4797315a 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -54,6 +54,9 @@ func Shortcuts() []common.Shortcut { AppsFileUpload, AppsFileDelete, AppsFileQuotaGet, + AppsCacheGet, + AppsCacheDelete, + AppsCacheClear, AppsGitCredentialInit, AppsGitCredentialList, AppsGitCredentialRemove, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index fccdb62f50..14c394b1d8 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -20,11 +20,12 @@ import ( // - 3 git-credential // - 5 session(create/list/get/stop/chat)+ 1 session-messages-list // - 8 openapi-key(list/get/create/update/enable/disable/delete/reset) -// - 3 plugin(install/uninstall/list)= 63。 -func TestAppsShortcuts_Returns63(t *testing.T) { +// - 3 cache(get/delete/clear) +// - 3 plugin(install/uninstall/list)= 66。 +func TestAppsShortcuts_Returns66(t *testing.T) { got := Shortcuts() - if len(got) != 63 { - t.Fatalf("Shortcuts() returned %d entries, want 63", len(got)) + if len(got) != 66 { + t.Fatalf("Shortcuts() returned %d entries, want 66", len(got)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index ff2761fef6..7cff52e778 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -39,6 +39,7 @@ lark-cli auth login --domain apps | 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) | | 逐条执行 SQL(SELECT / DML / DDL) | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md) | | 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) | +| 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | diff --git a/skills/lark-apps/references/lark-apps-cache.md b/skills/lark-apps/references/lark-apps-cache.md new file mode 100644 index 0000000000..e7006740d7 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-cache.md @@ -0,0 +1,61 @@ +# apps cache 域命令(应用运行时缓存调试) + +调试妙搭应用的运行时缓存:查看某个缓存 key 的内容、删除单个 key、清空某个环境的全部缓存。缓存是应用为了加速而临时存放的数据,删除或清空后,应用下次用到时会自动重新取最新数据。命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要排查「某个缓存 key 里存的是什么 / 有没有命中」、想删掉某个 key 让应用下次拿到最新数据、或想清空某个环境的缓存做快速恢复时。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+cache-get` | 查一个缓存 key 的内容与信息 | `--key`、`--environment`、`--format` | +| `+cache-delete` | 删一个缓存 key(重复删不会报错;不需 `--yes`) | `--key`、`--environment` | +| `+cache-clear` | 清空指定环境下的全部缓存(**高危**) | `--environment`、`--yes` | + +> 所有命令都需 `--app-id`。 + +## 约定(先读) + +- **环境 `--environment dev|online`(可省略)**:缓存按运行环境隔离。不指定时按应用当前的环境配置自动选择——有多环境的应用默认落到开发环境 `dev`,没有多环境的就是线上 `online`;返回结果里的 `environment` 会告诉你这次实际操作的是哪个环境。想固定就显式传。 +- **缓存 key 用 `--key` 传**:传业务里使用的那个 key;是否合法(非空、长度等)由服务端校验,不合法会返回错误。 +- **风险分级**:`+cache-clear` 会清掉整个环境的缓存,是高危操作,不带 `--yes` 会被确认关卡拦下;`+cache-delete` 只删单个 key、影响小,不需 `--yes`。 +- **`+cache-get` 的内容有两种展示**:`--format json`(默认)原样返回缓存内容,适合精确比对;`--format pretty` 会把内容格式化展开,更便于阅读。 + +## 各命令 + +### +cache-get +按 `--key` 查单个缓存。命中时返回:是否存在、剩余有效期(TTL)、内容及其大小;未命中(或已过期)时只返回 `exists=false`、不带内容。 + +> 每次查询都会连内容一起返回(没有「只看信息、不取内容」的模式),内容可能较大——只是想确认「在不在 / 还有多久过期」时,留意别占用太多上下文。 + +```bash +lark-cli apps +cache-get --app-id app_xxx --key spotbonus:2026:winners:list:v1 +lark-cli apps +cache-get --app-id app_xxx --environment online --key --format pretty +``` + +### +cache-delete +删一个缓存 key。**重复删、或删一个本就不存在的 key,都算成功**(返回删除数量 0)、不会报错;删中则返回删除数量 1。删掉后应用下次会自动重新取最新数据,影响小,故不需 `--yes`。 + +```bash +lark-cli apps +cache-delete --app-id app_xxx --environment dev --key +``` + +### +cache-clear(高危) +清空当前应用在**指定环境**下的全部缓存,用于定位不到具体 key 时的快速恢复。影响面是整个环境,必须带 `--yes`;返回本次清除的 key 数量。动手前可先 `--dry-run` 预览将要执行的操作。 + +```bash +lark-cli apps +cache-clear --app-id app_xxx --environment dev --yes +``` + +## 错误与边界 + +- **key 不合法 / 缓存服务暂时不可用**:命令会返回带说明的错误,按 `error.hint` 转述给用户;「服务暂时不可用」这类可稍后重试。 + +## Agent 规则 + +- **写操作先定环境**:`+cache-clear` / `+cache-delete` 不指定 `--environment` 时会落到自动选中的环境——**没有多环境的应用会直接作用到线上 `online`(生产)**。不确定应用有没有多环境时,写操作显式传 `--environment`;纯查看(`+cache-get`)影响小,可以省略。 +- **`+cache-clear` 会清掉整个环境的缓存**:执行前先跟用户确认环境无误、说明会清掉该环境全部缓存。已明确授权可直接带 `--yes`;遇到确认关卡(`confirmation_required`,exit 10)按 lark-shared 约定与用户确认后再补 `--yes` 重试,不要静默追加。 +- **排查缓存内容优先用 `+cache-get`**:想看结构化、易读的内容用 `--format pretty`;想拿原始内容做精确比对用默认 JSON。 +- **删 key 前先对齐 key**:用户只描述了业务含义、没给准确 key 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。