From 1f8d659ee168dbbdb286a3269daaf0232e23f8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Mon, 22 Jun 2026 11:15:06 +0800 Subject: [PATCH] feat(apps): miaoda db (data/audit/env/recovery/quota) + file storage commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the second batch of apps db commands — data import/export, DDL changelog, row-level audit (status/enable/disable/list), dev→online env diff/migrate, point-in-time recovery diff/apply, and db quota — plus the full file-storage command set (list/get/sign/download/upload/delete/quota-get). Consolidate the apps skill references: one lark-apps-db.md for the whole db domain (db-execute kept separate) and one lark-apps-file.md for storage; drop the per-command db reference files and update SKILL.md routing. Change-Id: Ica42c0021332c3dbbf7eb20cab6fff18f5ef67ba --- shortcuts/apps/apps_db_audit_list.go | 298 +++++++++++++++++ shortcuts/apps/apps_db_audit_set.go | 139 ++++++++ shortcuts/apps/apps_db_audit_status.go | 137 ++++++++ shortcuts/apps/apps_db_audit_test.go | 298 +++++++++++++++++ shortcuts/apps/apps_db_changelog_list.go | 148 +++++++++ shortcuts/apps/apps_db_changelog_list_test.go | 126 +++++++ shortcuts/apps/apps_db_data_export.go | 189 +++++++++++ shortcuts/apps/apps_db_data_export_test.go | 178 ++++++++++ shortcuts/apps/apps_db_data_import.go | 136 ++++++++ shortcuts/apps/apps_db_data_import_test.go | 143 ++++++++ shortcuts/apps/apps_db_env_migrate.go | 182 ++++++++++ .../apps/apps_db_env_recovery_quota_test.go | 312 ++++++++++++++++++ shortcuts/apps/apps_db_quota_get.go | 84 +++++ shortcuts/apps/apps_db_recovery.go | 261 +++++++++++++++ shortcuts/apps/apps_file_delete.go | 147 +++++++++ shortcuts/apps/apps_file_delete_test.go | 120 +++++++ shortcuts/apps/apps_file_download.go | 117 +++++++ shortcuts/apps/apps_file_download_test.go | 113 +++++++ shortcuts/apps/apps_file_get.go | 85 +++++ shortcuts/apps/apps_file_get_test.go | 74 +++++ shortcuts/apps/apps_file_list.go | 143 ++++++++ shortcuts/apps/apps_file_list_test.go | 243 ++++++++++++++ shortcuts/apps/apps_file_quota_get.go | 84 +++++ shortcuts/apps/apps_file_quota_get_test.go | 80 +++++ shortcuts/apps/apps_file_sign.go | 81 +++++ shortcuts/apps/apps_file_sign_test.go | 65 ++++ shortcuts/apps/apps_file_upload.go | 199 +++++++++++ shortcuts/apps/apps_file_upload_test.go | 161 +++++++++ shortcuts/apps/db_common.go | 174 ++++++++++ shortcuts/apps/file_common.go | 222 +++++++++++++ shortcuts/apps/shortcuts.go | 19 ++ shortcuts/apps/shortcuts_test.go | 14 +- skills/lark-apps/SKILL.md | 4 +- .../references/lark-apps-db-env-create.md | 31 -- .../references/lark-apps-db-table-get.md | 29 -- .../references/lark-apps-db-table-list.md | 31 -- skills/lark-apps/references/lark-apps-db.md | 158 +++++++++ skills/lark-apps/references/lark-apps-file.md | 94 ++++++ 38 files changed, 5022 insertions(+), 97 deletions(-) create mode 100644 shortcuts/apps/apps_db_audit_list.go create mode 100644 shortcuts/apps/apps_db_audit_set.go create mode 100644 shortcuts/apps/apps_db_audit_status.go create mode 100644 shortcuts/apps/apps_db_audit_test.go create mode 100644 shortcuts/apps/apps_db_changelog_list.go create mode 100644 shortcuts/apps/apps_db_changelog_list_test.go create mode 100644 shortcuts/apps/apps_db_data_export.go create mode 100644 shortcuts/apps/apps_db_data_export_test.go create mode 100644 shortcuts/apps/apps_db_data_import.go create mode 100644 shortcuts/apps/apps_db_data_import_test.go create mode 100644 shortcuts/apps/apps_db_env_migrate.go create mode 100644 shortcuts/apps/apps_db_env_recovery_quota_test.go create mode 100644 shortcuts/apps/apps_db_quota_get.go create mode 100644 shortcuts/apps/apps_db_recovery.go create mode 100644 shortcuts/apps/apps_file_delete.go create mode 100644 shortcuts/apps/apps_file_delete_test.go create mode 100644 shortcuts/apps/apps_file_download.go create mode 100644 shortcuts/apps/apps_file_download_test.go create mode 100644 shortcuts/apps/apps_file_get.go create mode 100644 shortcuts/apps/apps_file_get_test.go create mode 100644 shortcuts/apps/apps_file_list.go create mode 100644 shortcuts/apps/apps_file_list_test.go create mode 100644 shortcuts/apps/apps_file_quota_get.go create mode 100644 shortcuts/apps/apps_file_quota_get_test.go create mode 100644 shortcuts/apps/apps_file_sign.go create mode 100644 shortcuts/apps/apps_file_sign_test.go create mode 100644 shortcuts/apps/apps_file_upload.go create mode 100644 shortcuts/apps/apps_file_upload_test.go create mode 100644 shortcuts/apps/file_common.go delete mode 100644 skills/lark-apps/references/lark-apps-db-env-create.md delete mode 100644 skills/lark-apps/references/lark-apps-db-table-get.md delete mode 100644 skills/lark-apps/references/lark-apps-db-table-list.md create mode 100644 skills/lark-apps/references/lark-apps-db.md create mode 100644 skills/lark-apps/references/lark-apps-file.md diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go new file mode 100644 index 000000000..916d9114b --- /dev/null +++ b/shortcuts/apps/apps_db_audit_list.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。 +// +// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。 +// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON +// (INSERT 无 before、DELETE 无 after),json 还原成对象。 +// +// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 / +// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。 +var AppsDBAuditList = common.Shortcut{ + Service: appsService, + Command: "+db-audit-list", + Description: "List row-change audit events for one or more tables (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-audit-list --app-id --table orders", + "Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + {Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"}, + {Name: "until", Desc: "filter: event at or before; same formats as --since"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if len(auditListTables(rctx)) == 0 { + return output.ErrValidation("--table is required (at least one table)") + } + return normalizeTimeFlags(rctx, "since", "until") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appAuditListPath(appID)). + Desc("List Miaoda app table audit events"). + Params(buildAuditListParams(rctx, auditListTables(rctx))) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + requested := auditListTables(rctx) + env := rctx.Str("env") + + // 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤, + // 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。 + // 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。 + queryTables := requested + var skipped []auditSkippedEntry + if len(requested) > 1 { + queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + // 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。 + if len(queryTables) == 0 { + out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped} + rctx.OutFormat(out, nil, func(w io.Writer) { + io.WriteString(w, "No audit events found.\n") + writeAuditSkipped(w, skipped, len(requested)) + }) + return nil + } + } + + data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + items := projectAuditLogItems(data["items"]) + data["items"] = items + // 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。 + if len(skipped) > 0 { + data["skipped"] = skipped + } else { + delete(data, "skipped") + } + multi := len(requested) > 1 + rctx.OutFormat(data, nil, func(w io.Writer) { + renderAuditListPretty(w, items, skipped, len(requested), multi) + }) + return nil + }, +} + +// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。 +type auditSkippedEntry struct { + Table string `json:"table"` + Reason string `json:"reason"` +} + +// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。 +func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) { + existing, err := fetchExistingTables(rctx, appID, env) + if err != nil { + return nil, nil, err + } + enabled, err := fetchAuditEnabledTables(rctx, appID, env) + if err != nil { + return nil, nil, err + } + valid := make([]string, 0, len(requested)) + var skipped []auditSkippedEntry + for _, t := range requested { + switch { + case !existing[t]: + skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"}) + case !enabled[t]: + skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"}) + default: + valid = append(valid, t) + } + } + return valid, skipped, nil +} + +// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。 +func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) { + existing := map[string]bool{} + token := "" + for { + params := map[string]interface{}{"env": env, "page_size": 100} + if token != "" { + params["page_token"] = token + } + data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil) + if err != nil { + return nil, err + } + for _, it := range asMapSlice(data["items"]) { + if name := common.GetString(it, "name"); name != "" { + existing[name] = true + } + } + token = common.GetString(data, "page_token") + if data["has_more"] != true || token == "" { + break + } + } + return existing, nil +} + +// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。 +func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) { + data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil) + if err != nil { + return nil, err + } + enabled := map[string]bool{} + for _, it := range asMapSlice(data["items"]) { + if it["enabled"] == true { + if name := common.GetString(it, "table"); name != "" { + enabled[name] = true + } + } + } + return enabled, nil +} + +// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。 +func asMapSlice(raw interface{}) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +// auditListTables 取 --table 切片,trim 去空。 +func auditListTables(rctx *common.RuntimeContext) []string { + out := make([]string, 0) + for _, t := range rctx.StrSlice("table") { + if v := strings.TrimSpace(t); v != "" { + out = append(out, v) + } + } + return out +} + +func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} { + params := map[string]interface{}{ + "env": rctx.Str("env"), + "tables": strings.Join(tables, ","), + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("since", "since") + addStr("until", "until") + addStr("page-token", "page_token") + return params +} + +type auditLogItem struct { + EventID string `json:"event_id"` + EventTime string `json:"event_time"` + TargetTable string `json:"target_table"` + Type string `json:"type"` + Operator *operatorRef `json:"operator,omitempty"` + Summary string `json:"summary"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` +} + +func projectAuditLogItems(raw interface{}) []auditLogItem { + arr, _ := raw.([]interface{}) + out := make([]auditLogItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + row := auditLogItem{ + EventID: common.GetString(m, "event_id"), + EventTime: common.GetString(m, "event_time"), + TargetTable: common.GetString(m, "target_table"), + Type: common.GetString(m, "type"), + Operator: parseOperator(common.GetString(m, "operator")), + Summary: common.GetString(m, "summary"), + } + // before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。 + if b := common.GetString(m, "before"); b != "" { + row.Before = safeParseJSON(b) + } + if a := common.GetString(m, "after"); a != "" { + row.After = safeParseJSON(a) + } + out = append(out, row) + } + return out +} + +// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。 +func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) { + if len(items) == 0 { + io.WriteString(w, "No audit events found.\n") + writeAuditSkipped(w, skipped, totalRequested) + return + } + var headers []string + if multi { + headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"} + } else { + headers = []string{"event_time", "type", "event_id", "operator", "summary"} + } + rows := make([][]string, 0, len(items)) + for _, it := range items { + cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)} + if multi { + cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...) + } + rows = append(rows, cells) + } + renderAlignedTable(w, headers, rows) + writeAuditSkipped(w, skipped, totalRequested) +} + +// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。 +func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) { + if len(skipped) == 0 { + return + } + parts := make([]string, 0, len(skipped)) + for _, s := range skipped { + parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason)) + } + fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", ")) +} diff --git a/shortcuts/apps/apps_db_audit_set.go b/shortcuts/apps/apps_db_audit_set.go new file mode 100644 index 000000000..a7bdea347 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_set.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// 审计保留期合法取值。 +var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"} + +const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id `" + +// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。 +// +// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。 +var AppsDBAuditEnable = common.Shortcut{ + Service: appsService, + Command: "+db-audit-enable", + Description: "Enable row-change audit logging for a table", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +db-audit-enable --app-id --table orders --retention 30d", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table to enable audit for", Required: true}, + {Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + }, + 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(appAuditSetPath(appID)). + Desc("Enable table audit"). + Params(map[string]interface{}{"env": rctx.Str("env")}). + Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + retention := rctx.Str("retention") + data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), + map[string]interface{}{"env": rctx.Str("env")}, + map[string]interface{}{"table": table, "enabled": true, "retention": retention}) + if err != nil { + return withAppsHint(err, dbAuditSetHint) + } + st := auditSetStatus(data, table) + ret := common.GetString(st, "retention") + if ret == "" { + ret = retention + } + out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret) + }) + return nil + }, +} + +// AppsDBAuditDisable 关闭某张表的行级审计。 +// +// POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。 +var AppsDBAuditDisable = common.Shortcut{ + Service: appsService, + Command: "+db-audit-disable", + Description: "Disable row-change audit logging for a table", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +db-audit-disable --app-id --table orders", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table to disable audit for", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + }, + 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(appAuditSetPath(appID)). + Desc("Disable table audit"). + Params(map[string]interface{}{"env": rctx.Str("env")}). + Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), + map[string]interface{}{"env": rctx.Str("env")}, + map[string]interface{}{"table": table, "enabled": false}) + if err != nil { + return withAppsHint(err, dbAuditSetHint) + } + st := auditSetStatus(data, table) + out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table")) + }) + return nil + }, +} + +// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。 +func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} { + if st, ok := data["status"].(map[string]interface{}); ok { + if common.GetString(st, "table") == "" { + st["table"] = table + } + return st + } + return map[string]interface{}{"table": table} +} diff --git a/shortcuts/apps/apps_db_audit_status.go b/shortcuts/apps/apps_db_audit_status.go new file mode 100644 index 000000000..e8e86ecab --- /dev/null +++ b/shortcuts/apps/apps_db_audit_status.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。 +// +// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false); +// 不指定返回所有已配置表。json 单表返对象、多表返数组;pretty 单表 key/value、多表表格。 +var AppsDBAuditStatus = common.Shortcut{ + Service: appsService, + Command: "+db-audit-status", + Description: "Show table audit (row-change tracking) status", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-audit-status --app-id ", + "Check one table: --table orders", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + {Name: "table", Desc: "show status for a single table (default: all configured tables)"}, + }, + 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(appAuditStatusPath(appID)). + Desc("Get table audit status"). + Params(buildAuditStatusParams(rctx)) + }, + 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("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + table := strings.TrimSpace(rctx.Str("table")) + items := projectAuditStatusItems(data["items"]) + // 单表查询但后端无记录 → 占位 enabled=false(与 miaoda 一致)。 + if table != "" && len(items) == 0 { + items = []map[string]interface{}{{"table": table, "enabled": false}} + } + // json:单表返对象、多表返数组。 + var out interface{} + if table != "" && len(items) == 1 { + out = items[0] + } else { + out = map[string]interface{}{"items": items} + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderAuditStatusPretty(w, items, table) + }) + return nil + }, +} + +func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{"env": rctx.Str("env")} + if t := strings.TrimSpace(rctx.Str("table")); t != "" { + params["table"] = t + } + return params +} + +// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。 +func projectAuditStatusItems(raw interface{}) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + row := map[string]interface{}{ + "table": common.GetString(m, "table"), + "enabled": m["enabled"] == true, + } + if v := common.GetString(m, "enabled_at"); v != "" { + row["enabled_at"] = v + } + if v := common.GetString(m, "retention"); v != "" { + row["retention"] = v + } + out = append(out, row) + } + return out +} + +func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) { + if len(items) == 0 { + io.WriteString(w, "No audit configuration found.\n") + return + } + yesNo := func(m map[string]interface{}) string { + if m["enabled"] == true { + return "yes" + } + return "no" + } + get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) } + // 单表 → key/value + if table != "" && len(items) == 1 { + it := items[0] + renderKeyValuePairs(w, [][2]string{ + {"table", common.GetString(it, "table")}, + {"enabled", yesNo(it)}, + {"enabled_at", get(it, "enabled_at")}, + {"retention", get(it, "retention")}, + }) + return + } + // 多表 → 表格 + headers := []string{"table", "enabled", "enabled_at", "retention"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")}) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_db_audit_test.go b/shortcuts/apps/apps_db_audit_test.go new file mode 100644 index 000000000..6805e49fb --- /dev/null +++ b/shortcuts/apps/apps_db_audit_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const ( + dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status" + dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set" + dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list" + dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables" +) + +// ── audit-status ── + +func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditStatus, + []string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // 单表无记录 → 占位对象 enabled:false(不是数组)。 + var env struct { + Data struct { + Table string `json:"table"` + Enabled bool `json:"enabled"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Table != "orders" || env.Data.Enabled { + t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data) + } +} + +func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"}, + map[string]interface{}{"table": "users", "enabled": false}, + }}}, + }) + if err := runAppsShortcut(t, AppsDBAuditStatus, + []string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") { + t.Fatalf("pretty table malformed:\n%s", got) + } +} + +// ── audit-enable / disable ── + +func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 缺 --table → cobra required, exit 1 + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --table error") + } + // 非法 retention → enum 校验 (validation) + factory2, stdout2, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2); err == nil || !strings.Contains(err.Error(), "retention") { + t.Fatalf("expected retention enum error, got %v", err) + } +} + +func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) { + // dry-run body {table, enabled:true, retention} + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + + // success + factory2, stdout2, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbAuditSetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") { + t.Fatalf("pretty: %s", stdout2.String()) + } +} + +func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditDisable, + []string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" { + t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body) + } + + factory2, stdout2, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbAuditSetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditDisable, + []string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") { + t.Fatalf("pretty: %s", stdout2.String()) + } +} + +// ── audit-list ── + +func TestAppsDBAuditList_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --table error") + } +} + +func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" { + t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"]) + } + if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("since not normalized: %v", a.Params["since"]) + } +} + +// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。 +func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "has_more": false, "page_token": "", + "items": []interface{}{map[string]interface{}{ + "event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users", + "type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field", + "before": `{"amount":100}`, "after": `{"amount":999}`, + }}, + }}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // operator → 对象;before/after → 还原成对象(非字符串)。 + for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `"skipped"`) { + t.Errorf("single-table query must not emit skipped:\n%s", got) + } + if strings.Contains(got, `"before": "{`) { + t.Errorf("before should be an object, not a JSON string:\n%s", got) + } +} + +func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("empty audit list should NOT error (ok read), got %v", err) + } + got := stdout.String() + if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") { + t.Fatalf("expected empty, no skipped for single table:\n%s", got) + } +} + +// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list, +// 不存在 / 未开启审计的表进 skipped。 +func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + // schema:orders/users/carts 存在,ghost 不存在。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbTablesListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"}, + }}}, + }) + // status:orders/users 开启审计,carts 未开启。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true}, + map[string]interface{}{"table": "carts", "enabled": false}, + }}}, + }) + // audit_list 只应被传入有效表 orders,users。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + OnMatch: func(req *http.Request) { + if got := req.URL.Query().Get("tables"); got != "orders,users" { + t.Errorf("audit_list tables = %q, want orders,users (filtered)", got) + } + }, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"}, + }}}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。 + for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。 +func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbTablesListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"name": "orders"}, + }}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + // 不注册 audit_list:若被调用会命中未注册请求而报错。 + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("all-filtered should still succeed (empty), got %v", err) + } + got := stdout.String() + if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") { + t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got) + } +} diff --git a/shortcuts/apps/apps_db_changelog_list.go b/shortcuts/apps/apps_db_changelog_list.go new file mode 100644 index 000000000..40b4f8eed --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const dbChangelogHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id --env dev`" + +// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。 +// +// GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。 +// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name}, +// json 还原成对象、pretty 只展示 name。 +var AppsDBChangelogList = common.Shortcut{ + Service: appsService, + Command: "+db-changelog-list", + Description: "List a Miaoda app database's DDL change history (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-changelog-list --app-id ", + "Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + {Name: "table", Desc: "filter by target table"}, + {Name: "change-id", Desc: "look up a single change by id (returns that one record only)"}, + {Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"}, + {Name: "until", Desc: "filter: changed at or before; same formats as --since"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "since", "until") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appChangelogListPath(appID)). + Desc("List Miaoda app DDL changelog"). + Params(buildChangelogParams(rctx)) + }, + 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("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + items := projectChangelogItems(data["items"]) + data["items"] = items + changeID := strings.TrimSpace(rctx.Str("change-id")) + rctx.OutFormat(data, nil, func(w io.Writer) { + renderChangelogPretty(w, items, changeID) + }) + return nil + }, +} + +func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "env": rctx.Str("env"), + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("table", "table") + addStr("change-id", "change_id") + addStr("since", "since") + addStr("until", "until") + addStr("page-token", "page_token") + return params +} + +type changelogItem struct { + ChangeID string `json:"change_id"` + ChangedAt string `json:"changed_at"` + Operator *operatorRef `json:"operator,omitempty"` + TargetTable string `json:"target_table"` + ChangeType string `json:"change_type"` + Summary string `json:"summary"` + Statement string `json:"statement,omitempty"` +} + +func projectChangelogItems(raw interface{}) []changelogItem { + arr, _ := raw.([]interface{}) + out := make([]changelogItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + out = append(out, changelogItem{ + ChangeID: common.GetString(m, "change_id"), + ChangedAt: common.GetString(m, "changed_at"), + Operator: parseOperator(common.GetString(m, "operator")), + TargetTable: common.GetString(m, "target_table"), + ChangeType: common.GetString(m, "change_type"), + Summary: common.GetString(m, "summary"), + Statement: common.GetString(m, "statement"), + }) + } + return out +} + +// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。 +func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) { + if len(items) == 0 { + if changeID != "" { + fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID) + } else { + io.WriteString(w, "No DDL changes found.\n") + } + return + } + headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{ + it.ChangeID, + dashIfEmpty(it.ChangedAt), + operatorName(it.Operator), + dashIfEmpty(it.TargetTable), + it.ChangeType, + dashIfEmpty(it.Summary), + }) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_db_changelog_list_test.go b/shortcuts/apps/apps_db_changelog_list_test.go new file mode 100644 index 000000000..2713fc526 --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list_test.go @@ -0,0 +1,126 @@ +// 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 dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list" + +func TestAppsDBChangelogList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected app-id error, got %v", err) + } +} + +func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--env", "dev", "--table", "orders", + "--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbChangelogURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" { + t.Fatalf("params = %v", a.Params) + } + if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"]) + } +} + +func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "since") { + t.Fatalf("expected --since validation, got %v", err) + } +} + +func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbChangelogURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "has_more": false, "page_token": "", + "items": []interface{}{map[string]interface{}{ + "change_id": "01J", "changed_at": "2026-04-15T10:30:00Z", + "operator": `{"id":"7311","name":"alice"}`, "target_table": "orders", + "change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...", + }}, + }}, + }) + if err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbChangelogURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") { + t.Fatalf("expected not-found message, got: %s", stdout.String()) + } +} + +func TestParseOperator_Cases(t *testing.T) { + if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" { + t.Fatalf("valid: %#v", op) + } + if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" { + t.Fatalf("name fallback to id: %#v", op) + } + if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" { + t.Fatalf("non-json raw: %#v", op) + } + if op := parseOperator(""); op != nil { + t.Fatalf("empty → nil, got %#v", op) + } + if operatorName(nil) != "—" { + t.Fatalf("nil operatorName should be —") + } +} + +func TestSafeParseJSON_Cases(t *testing.T) { + if v := safeParseJSON(`{"a":1}`); v == nil { + t.Fatalf("valid json → object") + } + if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" { + t.Fatalf("invalid json → raw string, got %v", v) + } +} diff --git a/shortcuts/apps/apps_db_data_export.go b/shortcuts/apps/apps_db_data_export.go new file mode 100644 index 000000000..59226cb6a --- /dev/null +++ b/shortcuts/apps/apps_db_data_export.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbDataExportMaxRows = 5000 +const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB + +const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets" + +// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。 +// +// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。 +// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。 +// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 .csv);上限 5000 行 / 1 MB。 +var AppsDBDataExport = common.Shortcut{ + Service: appsService, + Command: "+db-data-export", + Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-data-export --app-id --table orders --output ./orders.csv", + "Format follows the --output extension: .csv / .json / .sql (default csv).", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "source table", Required: true}, + {Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default:
.csv)"}, + {Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "source db environment"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("table")) == "" { + return output.ErrValidation("--table is required") + } + if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows { + return output.ErrValidation("--limit must be a positive integer ≤ %d", dbDataExportMaxRows) + } + if _, _, err := exportFormatAndOutput(rctx); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + format, _, _ := exportFormatAndOutput(rctx) + return common.NewDryRunAPI(). + GET(appDataExportPath(appID)). + Desc("Export Miaoda app table data (raw bytes)"). + Params(map[string]interface{}{ + "env": rctx.Str("env"), "table": strings.TrimSpace(rctx.Str("table")), + "format": format, "limit": rctx.Int("limit"), + }) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + format, out, err := exportFormatAndOutput(rctx) + if err != nil { + return err + } + + // 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。 + // total 查询失败不阻断导出——回退到按导出文件内容数行。 + total, totalErr := queryExportTotal(rctx, appID, rctx.Str("env"), table) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: appDataExportPath(appID), + QueryParams: larkcore.QueryParams{ + "env": []string{rctx.Str("env")}, + "table": []string{table}, + "format": []string{format}, + "limit": []string{strconv.Itoa(rctx.Int("limit"))}, + }, + }) + if err != nil { + return withAppsHint(output.ErrNetwork("export request failed: %v", err), dbDataExportHint) + } + // 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。 + if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' { + if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil { + return withAppsHint(cerr, dbDataExportHint) + } + } + if resp.StatusCode >= 400 { + return withAppsHint(output.ErrNetwork("export failed: HTTP %d", resp.StatusCode), dbDataExportHint) + } + body := resp.RawBody + if len(body) > dbDataExportMaxBytes { + return output.ErrValidation("export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body)) + } + + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: int64(len(body)), + }, bytes.NewReader(body)) + if err != nil { + return output.ErrValidation("--output: %v", err) + } + // 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。 + rows := 0 + if totalErr == nil { + rows = total + if lim := rctx.Int("limit"); rows > lim { + rows = lim + } + } else { + rows = countDataRows(body, format) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + result := map[string]interface{}{ + "table": table, "output": resolved, "format": format, + "rows": rows, "size_bytes": saved.Size(), + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows) + }) + return nil + }, +} + +// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。 +// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。 +func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) { + raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), + map[string]interface{}{"env": env, "page_size": 1}, nil) + if err != nil { + return 0, err + } + return totalAsInt(raw["total"]), nil +} + +// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。 +func totalAsInt(v interface{}) int { + if f, ok := numericAsFloat(v); ok { + return int(f) + } + if s, ok := v.(string); ok { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + } + return 0 +} + +// exportFormatAndOutput 由 --output 推断数据格式与落盘路径: +// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出
.csv。 +func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) { + table := strings.TrimSpace(rctx.Str("table")) + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + return "csv", table + ".csv", nil + } + f, ferr := resolveDataFormat(filepath.Ext(out), true) + if ferr != nil { + return "", "", ferr + } + return f, out, nil +} diff --git a/shortcuts/apps/apps_db_data_export_test.go b/shortcuts/apps/apps_db_data_export_test.go new file mode 100644 index 000000000..4924ddf0b --- /dev/null +++ b/shortcuts/apps/apps_db_data_export_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export" +const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records" + +func TestAppsDBDataExport_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 缺 --table → cobra required-flag, exit 1 + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected required-flag error for missing --table") + } +} + +func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) { + for _, lim := range []string{"0", "-1", "5001"} { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("limit=%s expected validation error, got %v", lim, err) + } + } +} + +func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "format") { + t.Fatalf("expected unsupported-format validation for .xml, got %v", err) + } +} + +// dry-run:format 跟随 --output 扩展名;缺省 csv。 +func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) { + cases := []struct{ output, wantFmt string }{ + {"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"}, + } + for _, c := range cases { + factory, stdout, _ := newAppsExecuteFactory(t) + args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"} + if c.output != "" { + args = append(args, "--output", c.output) + } + if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbDataExportURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" { + t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt) + } + if _, ok := a.Params["limit"]; !ok { + t.Errorf("dry-run missing limit param") + } + } +} + +// 成功:先查 records 列表 total 计行,再把原始字节落盘。 +func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + // 第 1 步:records 列表 total=2(行数来源)。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}}, + }) + // 第 2 步:导出原始字节。 + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: dbDataExportURL, + RawBody: []byte("id,name\n1,a\n2,b\n"), + Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(dir + "/orders.csv") + if err != nil || string(b) != "id,name\n1,a\n2,b\n" { + t.Fatalf("output file wrong: %q err=%v", string(b), err) + } + got := stdout.String() + if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) { + t.Fatalf("output json missing fields:\n%s", got) + } +} + +// 行数取自 records total,且按 --limit 截顶(min(total, limit))。 +func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbDataExportURL, + RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), `"rows": 100`) { + t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String()) + } +} + +// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。 +func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbDataExportURL, + RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("export should still succeed via fallback, got %v", err) + } + b, _ := os.ReadFile(dir + "/orders.csv") + if string(b) != "id,name\n1,a\n2,b\n3,c\n" { + t.Fatalf("file not written on fallback path: %q", string(b)) + } + if !strings.Contains(stdout.String(), `"rows": 3`) { + t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String()) + } +} + +// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。 +func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: dbDataExportURL, + RawBody: []byte(`{"code":1254043,"msg":"table not found"}`), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String()) + } + if _, statErr := os.Stat("nope.csv"); statErr == nil { + t.Fatalf("error path must not write the output file") + } +} diff --git a/shortcuts/apps/apps_db_data_import.go b/shortcuts/apps/apps_db_data_import.go new file mode 100644 index 000000000..c79382393 --- /dev/null +++ b/shortcuts/apps/apps_db_data_import.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB + +const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches" + +// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。 +// +// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与 +// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成 +// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。 +var AppsDBDataImport = common.Shortcut{ + Service: appsService, + Command: "+db-data-import", + Description: "Import rows from a local csv/json file into a Miaoda app table", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-data-import --app-id --file ./orders.csv --yes", + "Table defaults to the file name; override with --table.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true}, + {Name: "table", Desc: "target table (default: file name without extension)"}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("file")) == "" { + return output.ErrValidation("--file is required") + } + // 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。 + if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil { + return err + } + if importTableName(rctx) == "" { + return output.ErrValidation("cannot infer target table from file name; specify --table") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + fileName := filepath.Base(strings.TrimSpace(rctx.Str("file"))) + return common.NewDryRunAPI(). + POST(appDataImportPath(appID)). + Desc("Import data file into Miaoda app table (multipart upload)"). + Params(map[string]interface{}{"env": rctx.Str("env"), "table": importTableName(rctx)}). + Body(map[string]interface{}{"file_name": fileName, "file": ""}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + file := strings.TrimSpace(rctx.Str("file")) + content, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return output.ErrValidation("--file: %v", err) + } + if len(content) > dbDataImportMaxBytes { + return output.ErrValidation("import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)) + } + fileName := filepath.Base(file) + table := importTableName(rctx) + + // multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。 + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddFile("file", bytes.NewReader(content)) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: appDataImportPath(appID), + QueryParams: larkcore.QueryParams{"env": []string{rctx.Str("env")}, "table": []string{table}}, + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + return withAppsHint(output.ErrNetwork("import request failed: %v", err), dbDataImportHint) + } + data, err := rctx.ClassifyAPIResponse(resp) + if err != nil { + return withAppsHint(err, dbDataImportHint) + } + + outTable := common.GetString(data, "table") + if outTable == "" { + outTable = table + } + rows := int64(0) + if f, ok := numericAsFloat(data["rows"]); ok { + rows = int64(f) + } + out := map[string]interface{}{"file": file, "table": outTable, "rows": rows} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows) + }) + return nil + }, +} + +// importTableName 取目标表名:--table 优先,否则文件名去扩展名。 +func importTableName(rctx *common.RuntimeContext) string { + if t := strings.TrimSpace(rctx.Str("table")); t != "" { + return t + } + f := strings.TrimSpace(rctx.Str("file")) + if f == "" { + return "" + } + base := filepath.Base(f) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/shortcuts/apps/apps_db_data_import_test.go b/shortcuts/apps/apps_db_data_import_test.go new file mode 100644 index 000000000..0dd885454 --- /dev/null +++ b/shortcuts/apps/apps_db_data_import_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import" + +// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。 +func chdirTemp(t *testing.T) string { + t.Helper() + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + return dir +} + +func TestAppsDBDataImport_RequiresAppID(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected app-id error, got %v", err) + } +} + +func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("data.txt", []byte("x\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "format") { + t.Fatalf("expected unsupported-format validation, got %v", err) + } +} + +func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required, got %v", err) + } +} + +func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) { + chdirTemp(t) + // >1MB → size 校验 + big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...) + _ = os.WriteFile("big.csv", big, 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout); err == nil || !strings.Contains(err.Error(), "1 MB") { + t.Fatalf("expected 1MB limit error, got %v", err) + } +} + +// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。 +func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--env", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbDataImportURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil { + t.Fatalf("dry-run body should carry file_name + file: %v", a.Body) + } + if _, ok := a.Body["format"]; ok { + t.Fatalf("format must no longer be sent: %v", a.Body) + } + if a.Params["env"] != "dev" || a.Params["table"] != "orders" { + t.Fatalf("dry-run params (env+table) = %v", a.Params) + } +} + +func TestAppsDBDataImport_Success(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbDataImportURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}}, + }) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) { + t.Fatalf("output missing fields:\n%s", got) + } +} + +func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Params["table"] != "customers" { + t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params) + } +} + diff --git a/shortcuts/apps/apps_db_env_migrate.go b/shortcuts/apps/apps_db_env_migrate.go new file mode 100644 index 000000000..88f7e7ecf --- /dev/null +++ b/shortcuts/apps/apps_db_env_migrate.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`" + +// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。 +// +// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。 +// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。 +var AppsDBEnvDiff = common.Shortcut{ + Service: appsService, + Command: "+db-env-diff", + Description: "Preview pending dev→online schema changes (no apply)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-env-diff --app-id ", + "Apply the previewed changes with +db-env-migrate --yes.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + 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(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true}) + }, + 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", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}) + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + from, to := common.GetString(data, "from"), common.GetString(data, "to") + changes := projectMigrationChanges(data["changes"]) + out := map[string]interface{}{"from": from, "to": to, "changes": changes} + rctx.OutFormat(out, nil, func(w io.Writer) { + renderMigrationDiff(w, from, to, changes) + }) + return nil + }, +} + +// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。 +// +// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status +// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。 +var AppsDBEnvMigrate = common.Shortcut{ + Service: appsService, + Command: "+db-env-migrate", + Description: "Publish pending dev→online schema changes (irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-env-migrate --app-id --yes", + "Preview first with +db-env-diff.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + 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(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false}) + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + from, to := common.GetString(submit, "from"), common.GetString(submit, "to") + taskID := common.GetString(submit, "task_id") + applied := intFromAny(submit["changes_applied"]) + if applied == 0 { + applied = len(projectMigrationChanges(submit["changes"])) + } + // 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。 + if taskID != "" { + final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "status")) { + case "success", "applied", "migrated": + return true, nil + case "failed": + return false, withAppsHint(output.ErrAPI(0, migrateFailMsg(d, taskID), nil), dbEnvMigrateHint) + } + return false, nil + }) + if perr != nil { + return perr + } + if n := intFromAny(final["changes_applied"]); n > 0 { + applied = n + } + } + out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied) + }) + return nil + }, +} + +type migrationChange struct { + Type string `json:"type"` + Table string `json:"table"` + Statement string `json:"statement"` +} + +func projectMigrationChanges(raw interface{}) []migrationChange { + arr, _ := raw.([]interface{}) + out := make([]migrationChange, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, migrationChange{ + Type: common.GetString(m, "type"), + Table: common.GetString(m, "table"), + Statement: common.GetString(m, "statement"), + }) + } + } + return out +} + +func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) { + if len(changes) == 0 { + fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to) + return + } + fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes)) + for _, c := range changes { + fmt.Fprintf(w, " %s\n", c.Statement) + } +} + +func migrateFailMsg(d map[string]interface{}, taskID string) string { + if m := common.GetString(d, "error_message"); m != "" { + return m + } + return fmt.Sprintf("migration apply failed (task_id=%s)", taskID) +} + +// intFromAny 把 JSON number / json.Number 转 int(计数用)。 +func intFromAny(v interface{}) int { + if f, ok := numericAsFloat(v); ok { + return int(f) + } + return 0 +} diff --git a/shortcuts/apps/apps_db_env_recovery_quota_test.go b/shortcuts/apps/apps_db_env_recovery_quota_test.go new file mode 100644 index 000000000..0af4631ce --- /dev/null +++ b/shortcuts/apps/apps_db_env_recovery_quota_test.go @@ -0,0 +1,312 @@ +// 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 ( + dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate" + dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status" + dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery" + dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status" + dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status" + dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota" +) + +// ── env-diff ── + +func TestAppsDBEnvDiff_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } +} + +func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "from": "dev", "to": "online", + "changes": []interface{}{ + map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") { + t.Fatalf("pretty diff malformed:\n%s", got) + } +} + +func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No pending changes from dev to online.") { + t.Fatalf("expected empty message, got: %s", stdout.String()) + } +} + +// ── env-migrate ── + +func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Body["dry_run"] != false { + t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body) + } +} + +// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。 +func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbEnvMigrateStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}}, + }) + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") { + t.Fatalf("pretty: %s", got) + } +} + +func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbEnvMigrateStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}}, + }) + err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "lock timeout") { + t.Fatalf("expected failed-status error, got %v", err) + } +} + +func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。 + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// ── recovery-diff ── + +func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --target error") + } +} + +func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"]) + } +} + +func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryDiffURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "preview_status": "success", "tables_affected": 2, "estimated_seconds": 12, + "changes": []interface{}{ + map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2}, + map[string]interface{}{"table": "carts", "action": "restore_table"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryDiffURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}}, + }) + err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "snapshot expired") { + t.Fatalf("expected preview-failed error, got %v", err) + } +} + +// ── recovery-apply ── + +func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No changes — database is already at this state.") { + t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String()) + } +} + +func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryApplyURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") { + t.Fatalf("pretty: %s", stdout.String()) + } +} + +func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// ── quota-get ── + +func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0, + "tables": 4, "views": 1, + }}, + }) + if err := runAppsShortcut(t, AppsDBQuotaGet, + []string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"usage", "(10.0%)", "tables", "4", "views", "1"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。 +func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0, + }}, + }) + if err := runAppsShortcut(t, AppsDBQuotaGet, + []string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") { + t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got) + } + if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") { + t.Fatalf("expected used + tables retained:\n%s", got) + } +} diff --git a/shortcuts/apps/apps_db_quota_get.go b/shortcuts/apps/apps_db_quota_get.go new file mode 100644 index 000000000..4ba7517cb --- /dev/null +++ b/shortcuts/apps/apps_db_quota_get.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBQuotaGet reports an app's database storage usage and object counts. +// +// GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时 +// 不输出(与 +file-quota-get 一致);tables / views 始终输出。 +var AppsDBQuotaGet = common.Shortcut{ + Service: appsService, + Command: "+db-quota-get", + Description: "Get an app's database storage usage", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-quota-get --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + }, + 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(appDbQuotaPath(appID)). + Desc("Get Miaoda app database storage usage"). + Params(map[string]interface{}{"env": rctx.Str("env")}) + }, + 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("GET", appDbQuotaPath(appID), map[string]interface{}{"env": rctx.Str("env")}, nil) + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + // 配额未对接(storage_quota_bytes=0/缺失)时删掉 quota / usage_percent。 + if q, ok := numericAsFloat(data["storage_quota_bytes"]); !ok || q == 0 { + delete(data, "storage_quota_bytes") + delete(data, "usage_percent") + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderDbQuotaPretty(w, data) + }) + return nil + }, +} + +// renderDbQuotaPretty 打 usage(已用 / 配额 (百分比))与 tables / views 行。 +func renderDbQuotaPretty(w io.Writer, data map[string]interface{}) { + used := humanBytes(data["storage_used_bytes"]) + usage := used + if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 { + pct := "" + if p, ok := numericAsFloat(data["usage_percent"]); ok { + pct = fmt.Sprintf(" (%.1f%%)", p) + } + usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct) + } + pairs := [][2]string{{"usage", usage}} + if f, ok := numericAsFloat(data["tables"]); ok { + pairs = append(pairs, [2]string{"tables", fmt.Sprintf("%d", int64(f))}) + } + if f, ok := numericAsFloat(data["views"]); ok { + pairs = append(pairs, [2]string{"views", fmt.Sprintf("%d", int64(f))}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_db_recovery.go b/shortcuts/apps/apps_db_recovery.go new file mode 100644 index 000000000..574c818f1 --- /dev/null +++ b/shortcuts/apps/apps_db_recovery.go @@ -0,0 +1,261 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)" + +// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。 +// +// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id, +// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。 +var AppsDBRecoveryDiff = common.Shortcut{ + Service: appsService, + Command: "+db-recovery-diff", + Description: "Preview restoring the database to a point in time (PITR diff)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-recovery-diff --app-id --target 2h", + "Apply with +db-recovery-apply --target --yes.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "target") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery"). + Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + target := rctx.Str("target") + preview, err := runRecoveryPreview(rctx, appID, target) + if err != nil { + return err + } + out := recoveryDiffOutput(target, preview) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderRecoveryDiff(w, target, out) + }) + return nil + }, +} + +// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。 +// +// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes, +// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。 +var AppsDBRecoveryApply = common.Shortcut{ + Service: appsService, + Command: "+db-recovery-apply", + Description: "Restore the database to a point in time (overwrites current data, irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-recovery-apply --app-id --target 2026-04-15T10:00:00Z --yes", + "Preview first with +db-recovery-diff.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "target") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery"). + Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + target := rctx.Str("target") + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false}) + if err != nil { + return withAppsHint(err, dbRecoveryHint) + } + // 目标=当前态 → 后端短路 no_changes,不轮询。 + if strings.ToLower(common.GetString(submit, "status")) == "no_changes" { + out := map[string]interface{}{"status": "no_changes", "target": target} + rctx.OutFormat(out, nil, func(w io.Writer) { + io.WriteString(w, "No changes — database is already at this state.\n") + }) + return nil + } + final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "status")) { + case "success", "restored", "ready": + return true, nil + case "failed": + msg := common.GetString(d, "error_message") + if msg == "" { + msg = fmt.Sprintf("recovery to %s failed", target) + } + return false, withAppsHint(output.ErrAPI(0, msg, nil), dbRecoveryHint) + } + return false, nil + }) + if perr != nil { + return perr + } + out := map[string]interface{}{"status": "restored", "target": target} + if n := intFromAny(final["restore_time_sec"]); n > 0 { + out["restore_time_sec"] = n + } + rctx.OutFormat(out, nil, func(w io.Writer) { + if n, ok := out["restore_time_sec"].(int); ok { + fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n) + } else { + fmt.Fprintf(w, "✓ Database restored to %s\n", target) + } + }) + return nil + }, +} + +// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。 +func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) { + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true}) + if err != nil { + return nil, withAppsHint(err, dbRecoveryHint) + } + prid := common.GetString(submit, "preview_request_id") + if prid == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id") + } + return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "preview_status")) { + case "success": + return true, nil + case "failed": + msg := common.GetString(d, "error_message") + if msg == "" { + msg = "recovery preview failed" + } + return false, withAppsHint(output.ErrAPI(0, msg, nil), dbRecoveryHint) + } + return false, nil + }) +} + +type recoveryChange struct { + Table string `json:"table"` + Inserted interface{} `json:"inserted,omitempty"` + Deleted interface{} `json:"deleted,omitempty"` + Action string `json:"action,omitempty"` + DroppedAt string `json:"dropped_at,omitempty"` +} + +// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。 +func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} { + arr, _ := preview["changes"].([]interface{}) + changes := make([]recoveryChange, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + changes = append(changes, recoveryChange{ + Table: common.GetString(m, "table"), + Inserted: m["inserted"], + Deleted: m["deleted"], + Action: common.GetString(m, "action"), + DroppedAt: common.GetString(m, "dropped_at"), + }) + } + tablesAffected := intFromAny(preview["tables_affected"]) + if tablesAffected == 0 { + tablesAffected = len(changes) + } + est := intFromAny(preview["estimated_seconds"]) + if est == 0 { + est = 30 // PRD 兜底 + } + return map[string]interface{}{ + "target": target, "tables_affected": tablesAffected, + "changes": changes, "estimated_seconds": est, + } +} + +func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) { + changes, _ := out["changes"].([]recoveryChange) + if len(changes) == 0 { + io.WriteString(w, "No changes — database is already at this state.\n") + return + } + fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target) + fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"])) + for _, c := range changes { + fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c)) + } + fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"])) +} + +// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。 +func describeRecoveryChange(c recoveryChange) string { + switch c.Action { + case "restore_table": + return "table will be restored" + case "drop_table": + return "table will be dropped" + case "alter_table": + return "table will be altered" + case "unavailable": + if c.DroppedAt != "" { + return "diff unavailable: " + c.DroppedAt + } + return "diff unavailable" + } + parts := make([]string, 0, 2) + if n := intFromAny(c.Inserted); n != 0 { + parts = append(parts, fmt.Sprintf("+%d rows", n)) + } + if n := intFromAny(c.Deleted); n != 0 { + parts = append(parts, fmt.Sprintf("-%d rows", n)) + } + if len(parts) == 0 { + return "no changes" + } + return strings.Join(parts, ", ") +} diff --git a/shortcuts/apps/apps_file_delete.go b/shortcuts/apps/apps_file_delete.go new file mode 100644 index 000000000..55655f128 --- /dev/null +++ b/shortcuts/apps/apps_file_delete.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileDelete batch-deletes files by remote path(high-risk-write,框架自动注入 --yes 确认)。 +// +// POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST +// (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths +// 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 path)。 +// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error,不翻成非 0 退出码(lark-cli 信封语义)。 +var AppsFileDelete = common.Shortcut{ + Service: appsService, + Command: "+file-delete", + Description: "Delete one or more files by remote path (batch)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +file-delete --app-id --path /1858537546760216.png --yes", + "Repeat --path for batch delete.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if len(cleanDeletePaths(rctx)) == 0 { + return output.ErrValidation("--path is required (at least one remote path)") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFileBatchRemovePath(appID)). + Desc("Batch delete Miaoda app files"). + Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + paths := cleanDeletePaths(rctx) + data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths}) + if err != nil { + return withAppsHint(err, fileListHint) + } + results := projectDeleteResults(data["results"], paths) + out := map[string]interface{}{"results": results} + rctx.OutFormat(out, nil, func(w io.Writer) { + renderFileDeletePretty(w, results) + }) + return nil + }, +} + +// cleanDeletePaths 取 --path 切片,trim 去空。 +func cleanDeletePaths(rctx *common.RuntimeContext) []string { + out := make([]string, 0) + for _, p := range rctx.StrSlice("path") { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} + +// projectDeleteResults 把后端 results[] 按下标 zip 回请求 paths,回填 path, +// 失败项把 error_code 包成 {code,message} 便于消费。 +func projectDeleteResults(raw interface{}, inputs []string) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(inputs)) + for i, input := range inputs { + var r map[string]interface{} + if i < len(arr) { + r, _ = arr[i].(map[string]interface{}) + } + status := "ok" + if r != nil && common.GetString(r, "status") != "" { + status = common.GetString(r, "status") + } + item := map[string]interface{}{"status": status, "path": input} + if status == "ok" { + if r != nil { + if f, ok := r["file"].(map[string]interface{}); ok { + item["file_name"] = common.GetString(f, "file_name") + } + } + } else { + code := "" + if r != nil { + code = common.GetString(r, "error_code") + } + if code == "" { + code = "DELETE_FAILED" + } + item["error"] = map[string]interface{}{ + "code": code, + "message": deleteErrorMessage(code, input), + } + } + out = append(out, item) + } + return out +} + +func deleteErrorMessage(code, path string) string { + if code == "FILE_NOT_FOUND" { + return fmt.Sprintf("File '%s' does not exist", path) + } + return fmt.Sprintf("Failed to delete '%s'", path) +} + +// renderFileDeletePretty 逐项打 ✓ / ✗,末行汇总 deleted 计数。 +func renderFileDeletePretty(w io.Writer, results []map[string]interface{}) { + okCount := 0 + for _, r := range results { + path := common.GetString(r, "path") + if common.GetString(r, "status") == "ok" { + fmt.Fprintf(w, "✓ %s\n", path) + okCount++ + continue + } + code := "" + if e, ok := r["error"].(map[string]interface{}); ok { + code = common.GetString(e, "code") + } + fmt.Fprintf(w, "✗ %s (%s)\n", path, code) + } + fmt.Fprintf(w, "\n%d/%d deleted\n", okCount, len(results)) +} diff --git a/shortcuts/apps/apps_file_delete_test.go b/shortcuts/apps/apps_file_delete_test.go new file mode 100644 index 000000000..4d529eb6b --- /dev/null +++ b/shortcuts/apps/apps_file_delete_test.go @@ -0,0 +1,120 @@ +// 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 fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove" + +func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout); err == nil || !strings.Contains(err.Error(), "path") { + t.Fatalf("expected path required error, got %v", err) + } +} + +// high-risk-write:无 --yes → confirmation_required(exit 10)。 +func TestAppsFileDelete_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required, got %v", err) + } +} + +func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != fileDeleteURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + paths, _ := a.Body["paths"].([]interface{}) + if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" { + t.Fatalf("body.paths = %v", a.Body["paths"]) + } +} + +// 部分失败仍 ok:true;results 按下标 zip 回 path;失败项带 error{code,message}。 +func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileDeleteURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}}, + map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"}, + }, + }}, + }) + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err) + } + got := stdout.String() + var env struct { + Data struct { + Results []map[string]interface{} `json:"results"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(got), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, got) + } + if len(env.Data.Results) != 2 { + t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got) + } + r0, r1 := env.Data.Results[0], env.Data.Results[1] + if r0["status"] != "ok" || r0["path"] != "/a.png" { + t.Errorf("result[0] = %v", r0) + } + if r1["status"] != "error" || r1["path"] != "/missing.png" { + t.Errorf("result[1] = %v (path must be back-filled by index)", r1) + } + if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" { + t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"]) + } +} + +func TestAppsFileDelete_PrettySummary(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileDeleteURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}}, + map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } +} diff --git a/shortcuts/apps/apps_file_download.go b/shortcuts/apps/apps_file_download.go new file mode 100644 index 000000000..ecf082f7a --- /dev/null +++ b/shortcuts/apps/apps_file_download.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "net/http" + "path" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileDownload downloads a file to a local path via a signed URL。 +// +// 两步:POST /apps/{app_id}/storage/file_sign 拿 signed_url(presigned,直连对象存储), +// 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。 +var AppsFileDownload = common.Shortcut{ + Service: appsService, + Command: "+file-download", + Description: "Download a file to a local path (via a signed URL)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-download --app-id --path /1858537546760216.png --output ./logo.png", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + {Name: "output", Desc: "local output path (default: remote file basename in cwd)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := requireFilePath(rctx.Str("path")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + remotePath, _ := requireFilePath(rctx.Str("path")) + return common.NewDryRunAPI(). + POST(appFileSignPath(appID)). + Desc("Sign a download URL, then GET it to --output"). + Body(map[string]interface{}{"path": remotePath}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + remotePath, err := requireFilePath(rctx.Str("path")) + if err != nil { + return err + } + + // 1. 签名拿 presigned signed_url。 + signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath}) + if err != nil { + return withAppsHint(err, fileListHint) + } + signedURL := common.GetString(signData, "signed_url") + if signedURL == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url") + } + + // 2. 直连 GET signed_url 落盘。 + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + out = path.Base(strings.TrimPrefix(remotePath, "/")) + if out == "" || out == "." || out == "/" { + out = "download" + } + } + req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) + if err != nil { + return output.ErrNetwork("build download request: %v", err) + } + resp, err := newFileTransferClient().Do(req) + if err != nil { + return output.ErrNetwork("download failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode) + } + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return output.ErrValidation("--output: %v", err) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + result := map[string]interface{}{ + "path": remotePath, + "output": resolved, + "size_bytes": saved.Size(), + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size())) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_file_download_test.go b/shortcuts/apps/apps_file_download_test.go new file mode 100644 index 000000000..ee5bf5e82 --- /dev/null +++ b/shortcuts/apps/apps_file_download_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const fileSignURLForDownload = "/open-apis/spark/v1/apps/app_x/storage/file_sign" + +func TestAppsFileDownload_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory, stdout); err == nil || !strings.Contains(err.Error(), "path") { + t.Fatalf("expected path error, got %v", err) + } +} + +func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload { + t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL) + } +} + +// sign → 客户端 GET presigned signed_url → 落盘 --output。 +func TestAppsFileDownload_EndToEnd(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "image/png") + io.WriteString(w, "PNGDATA") + })) + defer srv.Close() + + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURLForDownload, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}}, + }) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--output", "out.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "out.png")) + if err != nil { + t.Fatalf("read output file: %v", err) + } + if string(b) != "PNGDATA" { + t.Fatalf("downloaded content = %q, want PNGDATA", b) + } + if !strings.Contains(stdout.String(), `"size_bytes": 7`) { + t.Errorf("output json missing size_bytes:7\n%s", stdout.String()) + } +} + +// 不传 --output → 默认远端 basename。 +func TestAppsFileDownload_DefaultsOutputToBasename(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "DATA") + })) + defer srv.Close() + + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURLForDownload, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}}, + }) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", "/1858537546760216.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "1858537546760216.png")); err != nil { + t.Fatalf("default output basename not written: %v", err) + } +} diff --git a/shortcuts/apps/apps_file_get.go b/shortcuts/apps/apps_file_get.go new file mode 100644 index 000000000..e98af163a --- /dev/null +++ b/shortcuts/apps/apps_file_get.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileGet gets one file's metadata by exact remote path(动词对齐 +file-list)。 +// +// GET /apps/{app_id}/storage/file?path=。file 仅按 path 精确寻址,无按名寻址。 +// pretty 渲染 key/value:file_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at / +// download_url(条件出现)。server created_at/created_by → uploaded_at/uploaded_by。 +var AppsFileGet = common.Shortcut{ + Service: appsService, + Command: "+file-get", + Description: "Get a single file's metadata by path", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-get --app-id --path /1858537546760216.png", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := requireFilePath(rctx.Str("path")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appFileGetPath(appID)). + Desc("Get Miaoda app file metadata"). + Params(buildFileGetParams(rctx)) + }, + 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("GET", appFileGetPath(appID), buildFileGetParams(rctx), nil) + if err != nil { + return withAppsHint(err, fileListHint) + } + info := projectFileInfo(data) + rctx.OutFormat(info, nil, func(w io.Writer) { + renderFileGetPretty(w, info) + }) + return nil + }, +} + +func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} { + path, _ := requireFilePath(rctx.Str("path")) + return map[string]interface{}{"path": path} +} + +// renderFileGetPretty 输出对齐 key/value;uploaded_by 只展示 name(id 仅 json 保留)。 +func renderFileGetPretty(w io.Writer, info fileInfo) { + pairs := [][2]string{ + {"file_name", dashIfEmpty(info.FileName)}, + {"path", info.Path}, + {"size", fileSizeDetail(info.SizeBytes)}, + {"type", dashIfEmpty(info.Type)}, + } + if info.UploadedBy != nil { + pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name}) + } + pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)}) + if info.DownloadURL != "" { + pairs = append(pairs, [2]string{"download_url", info.DownloadURL}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_file_get_test.go b/shortcuts/apps/apps_file_get_test.go new file mode 100644 index 000000000..9c4d34bee --- /dev/null +++ b/shortcuts/apps/apps_file_get_test.go @@ -0,0 +1,74 @@ +// 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 fileGetURL = "/open-apis/spark/v1/apps/app_x/storage/file" + +func TestAppsFileGet_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", " ", "--path", "/x.png", "--as", "user"}, factory, stdout); err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected app-id error, got %v", err) + } + factory2, stdout2, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory2, stdout2); err == nil || !strings.Contains(err.Error(), "path") { + t.Fatalf("expected path error, got %v", err) + } +} + +func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" { + t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params) + } +} + +func TestAppsFileGet_SuccessAndPrettyKeyValue(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileGetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "logo.png", "path": "/1858537546760216.png", + "size_bytes": 24580, "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + "created_by": `{"id":"7311","name":"alice"}`, + }}, + }) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", "/1858537546760216.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // pretty key/value:size 含 bytes、uploaded_by 只展示 name。 + for _, want := range []string{"file_name:", "24 KB (24580 bytes)", "uploaded_by: alice", "uploaded_at: 2026-04-15T10:30:00Z"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } + // pretty 不该泄漏 user id。 + if strings.Contains(got, "7311") { + t.Errorf("pretty should show name only, not id:\n%s", got) + } +} diff --git a/shortcuts/apps/apps_file_list.go b/shortcuts/apps/apps_file_list.go new file mode 100644 index 000000000..3a12530b9 --- /dev/null +++ b/shortcuts/apps/apps_file_list.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。 +// +// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt / +// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。 +// file 域不分 dev/online,无 --env。 +// +// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。 +// server 字段 created_at → 产品语义 uploaded_at。 +var AppsFileList = common.Shortcut{ + Service: appsService, + Command: "+file-list", + Description: "List files in a Miaoda app's storage (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-list --app-id ", + "Tip: filter fields with --jq, e.g. -q '.data.items[].path'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "name", Desc: "filter by exact file name"}, + {Name: "path", Desc: "filter by exact remote path"}, + {Name: "type", Desc: "filter by MIME type"}, + {Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"}, + {Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"}, + {Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"}, + {Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + // 设计原则三: 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。 + for _, f := range []string{"uploaded-since", "uploaded-until"} { + if strings.TrimSpace(rctx.Str(f)) == "" { + continue + } + n, err := normalizeTimestamp(rctx.Str(f)) + if err != nil { + return output.ErrValidation("--%s: %v", f, err) + } + _ = rctx.Cmd.Flags().Set(f, n) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appFileListPath(appID)). + Desc("List Miaoda app files"). + Params(buildFileListParams(rctx)) + }, + 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("GET", appFileListPath(appID), buildFileListParams(rctx), nil) + if err != nil { + return withAppsHint(err, fileListHint) + } + // 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。 + items := projectFileItems(data["items"]) + data["items"] = items + rctx.OutFormat(data, nil, func(w io.Writer) { + renderFileListPretty(w, items) + }) + return nil + }, +} + +func projectFileItems(raw interface{}) []fileInfo { + arr, _ := raw.([]interface{}) + out := make([]fileInfo, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, projectFileInfo(m)) + } + } + return out +} + +func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("name", "name") + addStr("path", "path") + addStr("type", "type") + addStr("uploaded-since", "uploaded_since") + addStr("uploaded-until", "uploaded_until") + addStr("page-token", "page_token") + if v := rctx.Int("size-gt"); v > 0 { + params["size_gt"] = v + } + if v := rctx.Int("size-lt"); v > 0 { + params["size_lt"] = v + } + return params +} + +// renderFileListPretty 5 列对齐表:file_name / path / size / type / uploaded_at。 +func renderFileListPretty(w io.Writer, items []fileInfo) { + if len(items) == 0 { + io.WriteString(w, "No files found.\n") + return + } + headers := []string{"file_name", "path", "size", "type", "uploaded_at"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{ + dashIfEmpty(it.FileName), + it.Path, + humanBytes(it.SizeBytes), + dashIfEmpty(it.Type), + dashIfEmpty(it.UploadedAt), + }) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_file_list_test.go b/shortcuts/apps/apps_file_list_test.go new file mode 100644 index 000000000..7a8799701 --- /dev/null +++ b/shortcuts/apps/apps_file_list_test.go @@ -0,0 +1,243 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +// 设计原则三: 四种格式 → 统一 RFC3339 UTC。 +func TestNormalizeTimestamp_AllFormats(t *testing.T) { + // 空串透传 + if got, err := normalizeTimestamp(" "); err != nil || got != "" { + t.Fatalf("empty → %q,%v want \"\",nil", got, err) + } + + // ISO 8601 带 TZ:Z 原样、显式偏移换算到 UTC + mustEq := func(in, want string) { + got, err := normalizeTimestamp(in) + if err != nil || got != want { + t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want) + } + } + mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z") + mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h + + // date / local datetime:按本地时区解释再转 UTC(与 time.ParseInLocation 对齐) + dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local) + mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339)) + ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local) + mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339)) + + // 相对:从现在往前推,结果应 ≈ now-dur(5s 容差) + for _, c := range []struct { + in string + dur time.Duration + }{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} { + got, err := normalizeTimestamp(c.in) + if err != nil { + t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err) + continue + } + ts, perr := time.Parse(time.RFC3339, got) + if perr != nil { + t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got) + continue + } + want := time.Now().Add(-c.dur) + if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second { + t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur) + } + } + + // 非法格式 → error + for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} { + if _, err := normalizeTimestamp(bad); err == nil { + t.Errorf("normalizeTimestamp(%q) expected error", bad) + } + } +} + +const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list" + +func TestAppsFileList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", " ", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected app-id required error, got %v", err) + } +} + +// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。 +func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", + "--name", "logo.png", "--path", "/x.png", "--type", "image/png", + "--size-gt", "100", "--size-lt", "9000", + "--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01", + "--page-size", "5", "--page-token", "cur-1", + "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + a := env.API[0] + if a.Method != "GET" || a.URL != fileListURL { + t.Fatalf("method/url = %s %s", a.Method, a.URL) + } + // 设计原则三:date 入参会被归一化为 RFC3339 UTC,期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。 + sinceN, _ := normalizeTimestamp("2026-01-01") + untilN, _ := normalizeTimestamp("2026-02-01") + wantStr := map[string]string{ + "name": "logo.png", "path": "/x.png", "type": "image/png", + "uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1", + } + for k, v := range wantStr { + if a.Params[k] != v { + t.Errorf("params.%s = %v, want %v", k, a.Params[k], v) + } + } + // 且确实归一化成了 UTC(以 Z 结尾),不是原样透传。 + if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") { + t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"]) + } + for _, k := range []string{"size_gt", "size_lt", "page_size"} { + if _, ok := a.Params[k]; !ok { + t.Errorf("params missing %s: %v", k, a.Params) + } + } +} + +// 0 值过滤器不下发(size-gt/lt 缺省 0、空字符串过滤器)。 +func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} { + if _, ok := env.API[0].Params[banned]; ok { + t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params) + } + } + if _, ok := env.API[0].Params["page_size"]; !ok { + t.Errorf("params should always carry page_size: %v", env.API[0].Params) + } +} + +// created_at/created_by → uploaded_at/uploaded_by;created_by 是 JSON 字符串 → parse 成对象。 +func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: fileListURL, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{ + "file_name": "logo.png", + "path": "/1858537546760216.png", + "size_bytes": 24580, + "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + "created_by": `{"id":"7311","name":"alice"}`, + "download_url": "/spark/app/x/1858537546760216.png", + }, + }, + }, + }, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} { + if !strings.Contains(got, want) { + t.Errorf("stdout missing %q:\n%s", want, got) + } + } + // created_* 不应再出现在输出。 + for _, banned := range []string{"created_at", "created_by"} { + if strings.Contains(got, banned) { + t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got) + } + } +} + +func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) { + // 非空:5 列表头。 + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + }}, + }}, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") { + t.Fatalf("pretty table malformed:\n%s", got) + } + + // 空:No files found. + factory2, stdout2, reg2 := newAppsExecuteFactory(t) + reg2.Register(&httpmock.Stub{ + Method: "GET", URL: fileListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "No files found.") { + t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String()) + } +} + +func TestParseFileUser_Cases(t *testing.T) { + if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" { + t.Fatalf("valid parse failed: %#v", u) + } + if u := parseFileUser(""); u != nil { + t.Errorf("empty → nil, got %#v", u) + } + if u := parseFileUser("not json"); u != nil { + t.Errorf("invalid → nil, got %#v", u) + } + if u := parseFileUser(`{"id":"","name":""}`); u != nil { + t.Errorf("all-empty → nil, got %#v", u) + } +} diff --git a/shortcuts/apps/apps_file_quota_get.go b/shortcuts/apps/apps_file_quota_get.go new file mode 100644 index 000000000..34c15552a --- /dev/null +++ b/shortcuts/apps/apps_file_quota_get.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileQuotaGet reports an app's file-storage usage(动词对齐 +db-quota-get)。 +// +// GET /apps/{app_id}/storage/file_quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时 +// 不输出(json 删字段、pretty 只打已用量)。 +var AppsFileQuotaGet = common.Shortcut{ + Service: appsService, + Command: "+file-quota-get", + Description: "Get an app's file-storage usage", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-quota-get --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + 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(appFileQuotaPath(appID)). + Desc("Get Miaoda app file-storage usage") + }, + 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("GET", appFileQuotaPath(appID), nil, nil) + if err != nil { + return withAppsHint(err, fileListHint) + } + // 配额未对接(storage_quota_bytes=0/缺失)时删掉 quota / usage_percent,避免误导。 + projectFileQuota(data) + rctx.OutFormat(data, nil, func(w io.Writer) { + renderFileQuotaPretty(w, data) + }) + return nil + }, +} + +// projectFileQuota 在配额未对接时移除 storage_quota_bytes / usage_percent。 +func projectFileQuota(data map[string]interface{}) { + if q, ok := numericAsFloat(data["storage_quota_bytes"]); !ok || q == 0 { + delete(data, "storage_quota_bytes") + delete(data, "usage_percent") + } +} + +// renderFileQuotaPretty 打 usage(已用 / 配额 (百分比))与 files 行。 +func renderFileQuotaPretty(w io.Writer, data map[string]interface{}) { + used := humanBytes(data["storage_used_bytes"]) + usage := used + if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 { + pct := "" + if p, ok := numericAsFloat(data["usage_percent"]); ok { + pct = fmt.Sprintf(" (%.1f%%)", p) + } + usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct) + } + pairs := [][2]string{{"usage", usage}} + if f, ok := numericAsFloat(data["files"]); ok { + pairs = append(pairs, [2]string{"files", fmt.Sprintf("%d", int64(f))}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_file_quota_get_test.go b/shortcuts/apps/apps_file_quota_get_test.go new file mode 100644 index 000000000..2969d2715 --- /dev/null +++ b/shortcuts/apps/apps_file_quota_get_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const fileQuotaURL = "/open-apis/spark/v1/apps/app_x/storage/file_quota" + +func TestAppsFileQuotaGet_QuotaConnectedShowsAllFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 157286400, + "storage_quota_bytes": 1073741824, + "usage_percent": 14.6, + "files": 42, + }}, + }) + if err := runAppsShortcut(t, AppsFileQuotaGet, + []string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"storage_quota_bytes"`, `"usage_percent"`, `"files"`} { + if !strings.Contains(got, want) { + t.Errorf("quota json missing %q:\n%s", want, got) + } + } +} + +// 配额未对接(=0):storage_quota_bytes / usage_percent 不输出。 +func TestAppsFileQuotaGet_UnconnectedOmitsQuotaFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 157286400, + "storage_quota_bytes": 0, + "usage_percent": 0, + "files": 42, + }}, + }) + if err := runAppsShortcut(t, AppsFileQuotaGet, + []string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, banned := range []string{"storage_quota_bytes", "usage_percent"} { + if strings.Contains(got, banned) { + t.Errorf("unconnected quota should omit %q:\n%s", banned, got) + } + } + if !strings.Contains(got, `"storage_used_bytes"`) || !strings.Contains(got, `"files"`) { + t.Errorf("should still show used/files:\n%s", got) + } +} + +func TestProjectFileQuota_DeletesZeroQuota(t *testing.T) { + data := map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(0), "usage_percent": float64(0), "files": 3} + projectFileQuota(data) + if _, ok := data["storage_quota_bytes"]; ok { + t.Errorf("zero quota should be deleted: %v", data) + } + if _, ok := data["usage_percent"]; ok { + t.Errorf("usage_percent should be deleted when quota=0: %v", data) + } + + data2 := map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(1024), "usage_percent": float64(9.8), "files": 3} + projectFileQuota(data2) + if _, ok := data2["storage_quota_bytes"]; !ok { + t.Errorf("non-zero quota should be kept: %v", data2) + } +} diff --git a/shortcuts/apps/apps_file_sign.go b/shortcuts/apps/apps_file_sign.go new file mode 100644 index 000000000..ef36d13e1 --- /dev/null +++ b/shortcuts/apps/apps_file_sign.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// fileSignMaxExpiresSeconds 是签名链接最长有效期(30 天)。超出 → 校验失败。 +const fileSignMaxExpiresSeconds = 30 * 24 * 60 * 60 + +// AppsFileSign generates a temporary signed download URL for a file。 +// +// POST /apps/{app_id}/storage/file_sign,body {path, expires_in}。 +// pretty 模式只打 signed_url(便于直接管道 / curl);json 返 {file_name,path,signed_url,expires_at}。 +var AppsFileSign = common.Shortcut{ + Service: appsService, + Command: "+file-sign", + Description: "Generate a temporary signed download URL for a file", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-sign --app-id --path /1858537546760216.png", + "Tip: curl the signed_url directly to download.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + {Name: "expires-in", Type: "int", Default: "86400", Desc: "link validity in seconds (max 2592000 = 30d)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if _, err := requireFilePath(rctx.Str("path")); err != nil { + return err + } + if rctx.Int("expires-in") > fileSignMaxExpiresSeconds { + return output.ErrValidation("--expires-in exceeds the maximum of %d seconds (30d)", fileSignMaxExpiresSeconds) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFileSignPath(appID)). + Desc("Sign a temporary download URL"). + Body(buildFileSignBody(rctx)) + }, + 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", appFileSignPath(appID), nil, buildFileSignBody(rctx)) + if err != nil { + return withAppsHint(err, fileListHint) + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintln(w, common.GetString(data, "signed_url")) + }) + return nil + }, +} + +func buildFileSignBody(rctx *common.RuntimeContext) map[string]interface{} { + path, _ := requireFilePath(rctx.Str("path")) + body := map[string]interface{}{"path": path} + if v := rctx.Int("expires-in"); v > 0 { + body["expires_in"] = v + } + return body +} diff --git a/shortcuts/apps/apps_file_sign_test.go b/shortcuts/apps/apps_file_sign_test.go new file mode 100644 index 000000000..b2cdaf5e3 --- /dev/null +++ b/shortcuts/apps/apps_file_sign_test.go @@ -0,0 +1,65 @@ +// 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 fileSignURL = "/open-apis/spark/v1/apps/app_x/storage/file_sign" + +func TestAppsFileSign_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + if ei, _ := a.Body["expires_in"].(float64); int(ei) != 3600 { + t.Fatalf("body.expires_in = %v, want 3600", a.Body["expires_in"]) + } +} + +func TestAppsFileSign_RejectsDurationOverMax(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "9999999", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "30d") { + t.Fatalf("expected duration-exceeded validation error, got %v", err) + } +} + +func TestAppsFileSign_PrettyPrintsSignedURL(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "x.png", "path": "/x.png", + "signed_url": "https://tos.example/x.png?sig=abc", "expires_at": "2026-04-16T10:30:00Z", + }}, + }) + if err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := strings.TrimSpace(stdout.String()) + if got != "https://tos.example/x.png?sig=abc" { + t.Fatalf("pretty should print only signed_url, got: %q", got) + } +} diff --git a/shortcuts/apps/apps_file_upload.go b/shortcuts/apps/apps_file_upload.go new file mode 100644 index 000000000..f8afa5c9c --- /dev/null +++ b/shortcuts/apps/apps_file_upload.go @@ -0,0 +1,199 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// fileUploadMaxBytes 是单文件上传上限(100 MB,对齐 miaoda)。 +const fileUploadMaxBytes = 100 * 1024 * 1024 + +// AppsFileUpload uploads a local file to an app's storage(三步直传)。 +// +// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id} +// 2. 客户端 PUT 文件字节到 presigned upload_url,取响应 ETag +// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据 +// file_name 取本地 basename;path 由平台生成 16 位 ID(不可指定)。仅收 --file。 +var AppsFileUpload = common.Shortcut{ + Service: appsService, + Command: "+file-upload", + Description: "Upload a local file to an app's storage", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +file-upload --app-id --file ./logo.png", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "file", Desc: "local file to upload (file_name = basename)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + f := strings.TrimSpace(rctx.Str("file")) + if f == "" { + return output.ErrValidation("--file is required") + } + st, err := rctx.FileIO().Stat(f) + if err != nil { + return output.ErrValidation("--file: %v", err) + } + if st.IsDir() { + return output.ErrValidation("--file must be a file, not a directory") + } + if st.Size() > fileUploadMaxBytes { + return output.ErrValidation("file size %d bytes exceeds the 100 MB upload limit", st.Size()) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFilePreUploadPath(appID)). + Desc("Pre-upload → client PUT bytes → callback (3-step)"). + Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + localPath := strings.TrimSpace(rctx.Str("file")) + content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath) + if err != nil { + return output.ErrValidation("--file: %v", err) + } + fileName := filepath.Base(localPath) + contentType := mimeByExt(fileName) + + // 1. pre-upload + pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{ + "file_name": fileName, + "file_size": len(content), + "content_type": contentType, + }) + if err != nil { + return withAppsHint(err, fileListHint) + } + uploadURL := common.GetString(pre, "upload_url") + uploadID := common.GetString(pre, "upload_id") + if uploadURL == "" || uploadID == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id") + } + + // 2. PUT 文件字节到 presigned URL,取 ETag(带 Content-Disposition 透传原始文件名) + etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName) + if err != nil { + return err + } + + // 3. callback + result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{ + "upload_id": uploadID, + "etag": etag, + }) + if err != nil { + return withAppsHint(err, fileListHint) + } + info := projectFileInfo(result) + rctx.OutFormat(info, nil, func(w io.Writer) { + renderFileUploadPretty(w, fileName, info) + }) + return nil + }, +} + +// putFileBytes 直连 PUT 文件字节到 presigned URL,返回响应的 ETag。 +// +// Content-Disposition 透传原始文件名:TOS 把它存成对象 metadata,callback 阶段后端 +// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key +// (平台 16 位 ID)当文件名 —— 即「上传后文件名变成 ID」的根因。 +func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content)) + if err != nil { + return "", output.ErrNetwork("build upload request: %v", err) + } + req.ContentLength = int64(len(content)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"") + resp, err := newFileTransferClient().Do(req) + if err != nil { + return "", output.ErrNetwork("upload failed: %v", err) + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 400 { + return "", output.ErrNetwork("upload failed: HTTP %d", resp.StatusCode) + } + return resp.Header.Get("ETag"), nil +} + +// sanitizeUploadFileName 对齐 miaoda:先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent +// (UTF-8 百分号编码,兼容中文等非 ASCII,且让 Content-Disposition header 合法),空则兜底 download_file。 +func sanitizeUploadFileName(name string) string { + var b strings.Builder + for _, r := range name { + switch r { + case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';': + continue + default: + b.WriteRune(r) + } + } + enc := encodeURIComponent(b.String()) + if enc == "" { + return "download_file" + } + return enc +} + +// encodeURIComponent 复刻 JS encodeURIComponent:除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。 +func encodeURIComponent(s string) string { + const keep = "-_.!~*'()" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 { + b.WriteByte(c) + } else { + b.WriteString(fmt.Sprintf("%%%02X", c)) + } + } + return b.String() +} + +// mimeByExt 按扩展名推断 Content-Type,未知回退 application/octet-stream。 +func mimeByExt(name string) string { + if t := mime.TypeByExtension(filepath.Ext(name)); t != "" { + return t + } + return "application/octet-stream" +} + +// renderFileUploadPretty 打 ✓ Uploaded + size / download_url。 +func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) { + fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path) + fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes)) + if info.DownloadURL != "" { + fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL) + } +} diff --git a/shortcuts/apps/apps_file_upload_test.go b/shortcuts/apps/apps_file_upload_test.go new file mode 100644 index 000000000..c71267534 --- /dev/null +++ b/shortcuts/apps/apps_file_upload_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil || !strings.Contains(err.Error(), "file") { + t.Fatalf("expected --file required error, got %v", err) + } +} + +func TestAppsFileUpload_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "directory") { + t.Fatalf("expected directory rejection, got %v", err) + } +} + +func TestAppsFileUpload_DryRunPreUpload(t *testing.T) { + // Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["file_name"] != "logo.png" { + t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"]) + } +} + +// 三步直传:pre-upload → 客户端 PUT 字节 → callback。 +func TestAppsFileUpload_EndToEnd(t *testing.T) { + var putBody []byte + var putContentType, putCD string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + putBody, _ = io.ReadAll(r.Body) + putContentType = r.Header.Get("Content-Type") + putCD = r.Header.Get("Content-Disposition") + w.Header().Set("ETag", `"etag-123"`) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil { + t.Fatal(err) + } + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png", + "download_url": "/spark/app/x/1858537546760216.png", + }}, + }) + + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if string(putBody) != "PNGBYTES" { + t.Fatalf("PUT body = %q, want file bytes", putBody) + } + if putContentType != "image/png" { + t.Errorf("PUT Content-Type = %q, want image/png", putContentType) + } + // 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。 + if putCD != `attachment; filename="logo.png"` { + t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD) + } + got := stdout.String() + if !strings.Contains(got, `"path": "/1858537546760216.png"`) { + t.Errorf("output missing uploaded path:\n%s", got) + } +} + +func TestSanitizeUploadFileName_Cases(t *testing.T) { + cases := []struct{ in, want string }{ + {"logo.png", "logo.png"}, + {"a b.png", "a%20b.png"}, // 空格 → %20(encodeURIComponent) + {`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符 + {"///", "download_file"}, // 全非法 → 兜底 + {"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码 + } + for _, c := range cases { + if got := sanitizeUploadFileName(c.in); got != c.want { + t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want) + } + } +} + +func TestMimeByExt_Cases(t *testing.T) { + if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") { + t.Errorf("mimeByExt(a.png)=%q want image/png", got) + } + if got := mimeByExt("data.unknownext"); got != "application/octet-stream" { + t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got) + } +} diff --git a/shortcuts/apps/db_common.go b/shortcuts/apps/db_common.go index 9f0391394..ce47ae20e 100644 --- a/shortcuts/apps/db_common.go +++ b/shortcuts/apps/db_common.go @@ -4,13 +4,49 @@ package apps import ( + "context" + "encoding/json" "fmt" "strings" + "time" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" ) +// pollUntil 轮询异步任务直到 check 判定终态。async migrate/recovery 用:dataloom 立即返 +// task_id/preview_request_id,CLI 自己 poll(避免单连接长挂被网关/SDK 30s 中断)。 +// 首次立即 fetch(不睡);check 返 done→返回;返 err→透传(失败终态);否则按 interval 间隔重试至 maxWait。 +func pollUntil(ctx context.Context, interval, maxWait time.Duration, + fetch func() (map[string]interface{}, error), + check func(map[string]interface{}) (done bool, err error)) (map[string]interface{}, error) { + maxAttempts := int(maxWait / interval) + if maxAttempts < 1 { + maxAttempts = 1 + } + for i := 0; ; i++ { + data, err := fetch() + if err != nil { + return nil, err + } + done, cerr := check(data) + if cerr != nil { + return nil, cerr + } + if done { + return data, nil + } + if i+1 >= maxAttempts { + return nil, output.ErrNetwork("timed out waiting for completion after %s", maxWait) + } + select { + case <-ctx.Done(): + return nil, output.ErrNetwork("cancelled while waiting: %v", ctx.Err()) + case <-time.After(interval): + } + } +} + // URL helpers for the db CLI commands. // appTablesPath 返回 app db 表列表 URL(复用存量「获取数据表列表」接口)。 @@ -33,6 +69,144 @@ func appDbEnvCreatePath(appID string) string { return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID)) } +// ── 多环境发布(env diff/migrate)/ 数据恢复(recovery)/ 配额 路由 ── + +func appEnvMigratePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID)) +} +func appEnvMigrateStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID)) +} +func appRecoveryPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID)) +} +func appRecoveryDiffStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery_diff_status", apiBasePath, validate.EncodePathSegment(appID)) +} +func appRecoveryApplyStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery_apply_status", apiBasePath, validate.EncodePathSegment(appID)) +} +func appDbQuotaPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID)) +} + +// ── 变更追溯(changelog / audit)路由 ── + +func appChangelogListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID)) +} +func appAuditStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID)) +} +func appAuditSetPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID)) +} +func appAuditListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// operatorRef 是 operator 的 {id,name}。后端用 JSON 字符串内嵌透传,CLI parse: +// json 输出还原成对象(下游能区分同名用户),pretty 只取 name。 +type operatorRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// parseOperator 解析 operator 字符串:空→nil;非 JSON→{raw,raw};JSON→{id,name}(name 空兜底 id)。 +func parseOperator(raw string) *operatorRef { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + if !strings.HasPrefix(s, "{") { + return &operatorRef{ID: s, Name: s} + } + var o operatorRef + if json.Unmarshal([]byte(s), &o) != nil { + return &operatorRef{ID: s, Name: s} + } + if o.Name == "" { + o.Name = o.ID + } + return &o +} + +// operatorName 取 operator 的展示名(pretty),空用 "—"。 +func operatorName(op *operatorRef) string { + if op == nil || op.Name == "" { + return "—" + } + return op.Name +} + +// safeParseJSON 把 before/after 的 JSON 字符串还原成结构化对象供下游消费;失败时透传原始串。 +func safeParseJSON(s string) interface{} { + var v interface{} + if json.Unmarshal([]byte(s), &v) == nil { + return v + } + return s +} + +// appDataImportPath 返回 db 数据导入 URL(新增 db/ 域段路由)。 +func appDataImportPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/data_import", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appDataExportPath 返回 db 数据导出 URL(返原始字节)。 +func appDataExportPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/data_export", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appTableRecordsPath 返回数据表记录列表 URL(复用 GetAppTableRecordList,其 total 即符合条件的记录总数)。 +func appTableRecordsPath(appID, table string) string { + return appTablePath(appID, table) + "/records" +} + +// resolveDataFormat 由文件扩展名推断数据格式。lark-cli 的 --format 已被框架占用(输出渲染), +// 故数据格式从文件名推断:import 接受 csv/json,export 还接受 sql。 +func resolveDataFormat(ext string, allowSQL bool) (string, error) { + raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + switch raw { + case "csv", "json": + return raw, nil + case "sql": + if allowSQL { + return "sql", nil + } + } + if allowSQL { + return "", output.ErrValidation("unsupported data format %q (file must end in .csv, .json or .sql)", raw) + } + return "", output.ErrValidation("unsupported data format %q (file must end in .csv or .json)", raw) +} + +// countDataRows 粗估数据行数(用于导入上限校验、导出兜底计数)。 +// csv:非空行数 - 1(表头);json:顶层数组长度,非数组算 1,解析失败算 0。 +func countDataRows(body []byte, format string) int { + if format == "csv" { + lines := 0 + for _, ln := range strings.Split(string(body), "\n") { + if strings.TrimRight(ln, "\r") != "" { + lines++ + } + } + if lines > 0 { + return lines - 1 + } + return 0 + } + var arr []json.RawMessage + if err := json.Unmarshal(body, &arr); err == nil { + return len(arr) + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err == nil { + return 1 + } + return 0 +} + // requireAppID trims --app-id and rejects blank, returning a uniform validation error. func requireAppID(raw string) (string, error) { id := strings.TrimSpace(raw) diff --git a/shortcuts/apps/file_common.go b/shortcuts/apps/file_common.go new file mode 100644 index 000000000..a006414eb --- /dev/null +++ b/shortcuts/apps/file_common.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + reTsRelative = regexp.MustCompile(`^([0-9]+)([smhdw])$`) + reTsDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`) + reTsLocalDateTime = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$`) +) + +// normalizeTimestamp 实现设计原则三的 多格式输入,统一归一化为 RFC3339 UTC: +// - 相对:30s / 5m / 2h / 3d / 1w(从现在往前推) +// - date:2026-04-15(本地时区 00:00:00) +// - local datetime:2026-04-15T10:00:00(本地时区,T 分隔) +// - ISO 8601 带 TZ:...Z(UTC)/ ...+08:00(显式偏移) +// +// 归一化到 UTC 是必须的:服务端对无 TZ 的串按 UTC 裸解析,故 date / local datetime 的「本地」 +// 语义只能在 CLI 端换算;相对时间服务端也不认。空串原样返回(调用方据此跳过该过滤)。 +func normalizeTimestamp(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", nil + } + if m := reTsRelative.FindStringSubmatch(s); m != nil { + n, _ := strconv.Atoi(m[1]) + var unit time.Duration + switch m[2] { + case "s": + unit = time.Second + case "m": + unit = time.Minute + case "h": + unit = time.Hour + case "d": + unit = 24 * time.Hour + case "w": + unit = 7 * 24 * time.Hour + } + return time.Now().Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil + } + if reTsDate.MatchString(s) { + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return "", fmt.Errorf("invalid date %q", s) + } + return t.UTC().Format(time.RFC3339), nil + } + if reTsLocalDateTime.MatchString(s) { + t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local) + if err != nil { + return "", fmt.Errorf("invalid local datetime %q", s) + } + return t.UTC().Format(time.RFC3339), nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UTC().Format(time.RFC3339), nil + } + return "", fmt.Errorf("invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s) +} + +// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。 +func newFileTransferClient() *http.Client { + return &http.Client{Transport: http.DefaultTransport} +} + +// URL helpers for the file (storage) CLI commands. +// +// 全部走 spark OpenAPI,path 形如 /open-apis/spark/v1/apps/{app_id}/storage/。 +// 路由段不含 HTTP 方法名(file_get→file、file_delete→file_batch_remove、file_quota_get→file_quota)。 + +func appFileListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +func appFileGetPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file", apiBasePath, validate.EncodePathSegment(appID)) +} + +func appFileSignPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_sign", apiBasePath, validate.EncodePathSegment(appID)) +} + +func appFilePreUploadPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_pre_upload", apiBasePath, validate.EncodePathSegment(appID)) +} + +func appFileUploadCallbackPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_upload_callback", apiBasePath, validate.EncodePathSegment(appID)) +} + +func appFileBatchRemovePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_batch_remove", apiBasePath, validate.EncodePathSegment(appID)) +} + +func appFileQuotaPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_quota", apiBasePath, validate.EncodePathSegment(appID)) +} + +// fileListHint 是 file 读类命令最可能的失败(错 app-id / 路径不存在)的恢复提示。 +const fileListHint = "verify --app-id is correct and you have access; list files with `lark-cli apps +file-list --app-id `" + +// requireFilePath trims --path and rejects blank, returning a uniform validation error. +func requireFilePath(raw string) (string, error) { + p := strings.TrimSpace(raw) + if p == "" { + return "", output.ErrValidation("--path is required") + } + return p, nil +} + +// fileUser 是 uploaded_by 的 {id,name}。OpenAPI 以 created_by 的 JSON 字符串透传,CLI parse。 +type fileUser struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// fileInfo 是 file 命令对外输出的白名单字段。 +// OpenAPI 字段 created_at / created_by → CLI 产品语义 uploaded_at / uploaded_by。 +type fileInfo struct { + FileName string `json:"file_name"` + Path string `json:"path"` + SizeBytes interface{} `json:"size_bytes,omitempty"` + Type string `json:"type,omitempty"` + UploadedBy *fileUser `json:"uploaded_by,omitempty"` + UploadedAt string `json:"uploaded_at,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// projectFileInfo 把 server 原始 file map 投影为 CLI fileInfo(created_*→uploaded_*)。 +func projectFileInfo(m map[string]interface{}) fileInfo { + return fileInfo{ + FileName: common.GetString(m, "file_name"), + Path: common.GetString(m, "path"), + SizeBytes: m["size_bytes"], + Type: common.GetString(m, "type"), + UploadedBy: parseFileUser(common.GetString(m, "created_by")), + UploadedAt: common.GetString(m, "created_at"), + DownloadURL: common.GetString(m, "download_url"), + } +} + +// parseFileUser 解析 created_by 的 JSON 字符串 {id,name};空 / 非法 / 全空 → nil。 +func parseFileUser(raw string) *fileUser { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + var u fileUser + if err := json.Unmarshal([]byte(s), &u); err != nil { + return nil + } + if u.ID == "" && u.Name == "" { + return nil + } + return &u +} + +// normalizeTimeFlags 把若干时间 flag(如 --since/--until/--uploaded-since)就地归一化为 RFC3339 UTC +// 并回写,供 build*Params 透传。空 flag 跳过;非法格式 → validation 错误。复用 normalizeTimestamp。 +func normalizeTimeFlags(rctx *common.RuntimeContext, flags ...string) error { + for _, f := range flags { + if strings.TrimSpace(rctx.Str(f)) == "" { + continue + } + n, err := normalizeTimestamp(rctx.Str(f)) + if err != nil { + return output.ErrValidation("--%s: %v", f, err) + } + _ = rctx.Cmd.Flags().Set(f, n) + } + return nil +} + +// dashIfEmpty 空白串用 "—" 占位(pretty 列对齐)。 +func dashIfEmpty(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return s +} + +// fileSizeDetail 把 size_bytes 渲染成 "24 KB (24580 bytes)"(pretty 单文件详情用)。 +func fileSizeDetail(raw interface{}) string { + n, ok := numericAsFloat(raw) + if !ok { + return "—" + } + return fmt.Sprintf("%s (%d bytes)", humanBytes(raw), int64(n)) +} + +// renderKeyValuePairs 输出对齐的 key: value(key 列按最长 key 右填充)。 +func renderKeyValuePairs(w io.Writer, pairs [][2]string) { + width := 0 + for _, p := range pairs { + if dw := displayWidth(p[0]); dw > width { + width = dw + } + } + for _, p := range pairs { + io.WriteString(w, p[0]+":") + if pad := width - displayWidth(p[0]); pad > 0 { + io.WriteString(w, strings.Repeat(" ", pad)) + } + io.WriteString(w, " "+p[1]+"\n") + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index d5a226585..faf1deb46 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -23,6 +23,25 @@ func Shortcuts() []common.Shortcut { AppsDBTableGet, AppsDBExecute, AppsDBEnvCreate, + AppsDBDataImport, + AppsDBDataExport, + AppsDBChangelogList, + AppsDBAuditStatus, + AppsDBAuditEnable, + AppsDBAuditDisable, + AppsDBAuditList, + AppsDBEnvDiff, + AppsDBEnvMigrate, + AppsDBRecoveryDiff, + AppsDBRecoveryApply, + AppsDBQuotaGet, + AppsFileList, + AppsFileGet, + AppsFileSign, + AppsFileDownload, + AppsFileUpload, + AppsFileDelete, + AppsFileQuotaGet, AppsGitCredentialInit, AppsGitCredentialList, AppsGitCredentialRemove, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 689bc9246..01cb4956a 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,12 +10,16 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 db(table-list/table-schema/sql/dev-init) -// + 3 git-credential + 5 session(create/list/get/stop/chat)= 23。 -func TestAppsShortcuts_Returns23(t *testing.T) { +// 6 基础 + 1 init + 3 publish + 1 env-pull +// + 16 db(table-list/table-schema/sql/dev-init/data-import/data-export/changelog-list/ +// audit-status/audit-enable/audit-disable/audit-list/ +// env-diff/env-migrate/recovery-diff/recovery-apply/quota-get) +// + 3 git-credential + 5 session(create/list/get/stop/chat) +// + 7 file(list/get/sign/download/upload/delete/quota-get)= 42。 +func TestAppsShortcuts_Returns42(t *testing.T) { got := Shortcuts() - if len(got) != 23 { - t.Fatalf("Shortcuts() returned %d entries, want 23", len(got)) + if len(got) != 42 { + t.Fatalf("Shortcuts() returned %d entries, want 42", len(got)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index d0475000d..d8a23b2f5 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -24,7 +24,9 @@ metadata: | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | -| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | +| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / 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) | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+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-db-env-create.md b/skills/lark-apps/references/lark-apps-db-env-create.md deleted file mode 100644 index 6dd933a2f..000000000 --- a/skills/lark-apps/references/lark-apps-db-env-create.md +++ /dev/null @@ -1,31 +0,0 @@ -# apps +db-env-create - -把存量单库应用初始化为 `dev` / `online` 多环境数据库。运行时命令事实以 `lark-cli apps +db-env-create --help` 为准。 - -## 何时用 - -仅用于存量单库应用需要拆成 `dev` / `online` 两套数据库的场景。普通查看表、查 schema、执行 SQL 不需要先初始化。注意:通过 `+create --app-type full_stack` 新建的应用通常已自带多环境,无需再初始化(重复初始化会返回「已初始化」错误)。 - -## 命令骨架 - -- 必填:`--app-id`。 -- `--env`:要创建的环境,由调用方传入,目前只支持 `dev`(默认 `dev`)。 -- `--sync-data`:bool 开关,传 `--sync-data` 则把现有 online 数据复制到新环境;不传则不复制(默认)。 -- risk 是 `high-risk-write`;单库拆成 dev/online 后不可逆。 - -## 示例 - -```bash -lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run -lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes -``` - -## 输出契约 - -- 成功读取 `data.status`、`data.environments`、`data.data_synced`;pretty 会提示是否初始化、多环境列表、是否同步数据。 -- 未确认时返回 `confirmation_required` / exit 10;按 lark-shared 询问用户后再补 `--yes` 重试。 -- 如果服务端提示已启用多环境(`Multi-env is already initialized`),转述状态即可,不要重复初始化。 - -## Agent 规则 - -不要静默追加 `--yes`。遇到 confirmation_required 时,按 `lark-shared` 的 exit-10 协议向用户确认不可逆风险;用户明确同意后才在原 argv 末尾追加 `--yes` 重试。 diff --git a/skills/lark-apps/references/lark-apps-db-table-get.md b/skills/lark-apps/references/lark-apps-db-table-get.md deleted file mode 100644 index 301aea685..000000000 --- a/skills/lark-apps/references/lark-apps-db-table-get.md +++ /dev/null @@ -1,29 +0,0 @@ -# apps +db-table-get - -查看妙搭应用数据库某张表的结构。运行时命令事实以 `lark-cli apps +db-table-get --help` 为准。 - -## 何时用 - -用于查看已知表的字段、索引、约束,或给 SQL/迁移生成提供依据。只想知道有哪些表时先 `+db-table-list`。 - -## 命令骨架 - -- 必填:`--app-id`、`--table`。 -- `--env` 枚举:`dev` / `online`,默认 `online`。 -- `--format pretty` 会向服务端请求 DDL,并直接输出 DDL 文本;默认 JSON 返回结构化 columns/indexes/constraints/stats。 - -## 示例 - -```bash -lark-cli apps +db-table-get --app-id app_xxx --table orders -lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty -``` - -## 输出契约 - -- 默认 JSON 读取 `data.name`、`columns`、`indexes`、`constraints`、`estimated_row_count`、`size_bytes`。 -- `--format pretty` stdout 是服务端返回的 DDL 文本,不是 JSON envelope;需要建表语句时可原样给用户。 - -## Agent 规则 - -需要给用户看建表语句或迁移参照时用 `--format pretty`;需要程序化分析字段/索引/约束时保留默认 JSON。 diff --git a/skills/lark-apps/references/lark-apps-db-table-list.md b/skills/lark-apps/references/lark-apps-db-table-list.md deleted file mode 100644 index 9a08a093a..000000000 --- a/skills/lark-apps/references/lark-apps-db-table-list.md +++ /dev/null @@ -1,31 +0,0 @@ -# apps +db-table-list - -列出妙搭应用某个数据库环境的数据表。运行时命令事实以 `lark-cli apps +db-table-list --help` 为准。 - -## 何时用 - -用于先摸清应用数据库里有哪些表,或在用户只给业务对象名时定位可能的表名。已知表名且要字段/索引时直接用 `+db-table-get`。 - -## 命令骨架 - -- 必填:`--app-id`。 -- `--env` 枚举:`dev` / `online`,默认 `online`。 -- 分页:`--page-size` 默认 20,`--page-token` 使用上一页 cursor。 -- pretty 输出列包含 `name`、`description`、`estimated_row_count`、`size`、`columns`(列数)。 - -## 示例 - -```bash -lark-cli apps +db-table-list --app-id app_xxx -lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 -``` - -## 输出契约 - -- 成功读取 `data.items[]`;每项字段是 `name`、`description`、`estimated_row_count`、`size_bytes`、`column_count`(列数)。CLI 默认不透出每表完整 `columns[]`(与 `+db-table-get` 重复且放大 token),只给 `column_count`;要完整列定义/索引/约束用 `+db-table-get`。 -- pretty 输出是 5 列扫描表:`name`、`description`、`estimated_row_count`、`size`、`columns`(即列数)。 -- 若响应带 `has_more=true`,用返回的 `page_token` / `next_page_token` 翻页。 - -## Agent 规则 - -用户说“本地/开发库/调试库”时优先 `--env dev`;线上问题排查用 `--env online`。如果 dev 返回服务端错误提示未初始化,多环境入口是 [`+db-env-create`](lark-apps-db-env-create.md)。 diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md new file mode 100644 index 000000000..d751b2987 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db.md @@ -0,0 +1,158 @@ +# apps db 域命令 + +管理妙搭应用数据库:看表与结构、初始化与发布多环境、数据搬运、变更治理、时间点恢复、用量。逐条跑 SQL(SELECT/DML/DDL)走 [`+db-execute`](lark-apps-db-execute.md)(单独一篇)。运行时命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要看应用里有哪些表 / 某张表的结构、把单库应用拆成 dev/online 多环境、把数据导进导出表、查谁在什么时候改了表结构或表数据、开关行级审计、把开发环境的库结构发布到线上、把库恢复到过去某个时间点、或看数据库用量时。逐条执行 SQL 走 [`+db-execute`](lark-apps-db-execute.md);文件存储(上传/下载文件)走 [`lark-apps-file.md`](lark-apps-file.md)。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+db-table-list` | 列出某环境的数据表 | `--env`、`--page-size`/`--page-token` | +| `+db-table-get` | 看单张表的结构(字段/索引/约束/DDL) | `--table`、`--env`、`--format` | +| `+db-env-create` | 把单库应用初始化为 dev/online 多环境(高危) | `--env`、`--sync-data`、`--yes` | +| `+db-data-export` | 把一张表的数据导出到本地文件 | `--table`、`--output`、`--limit`、`--env` | +| `+db-data-import` | 把本地 csv/json 文件导进一张表(高危) | `--file`、`--table`、`--env`、`--yes` | +| `+db-changelog-list` | 查表结构变更(DDL)历史 | `--table`、`--change-id`、`--since`/`--until`、`--env` | +| `+db-audit-status` | 看哪些表开了行级审计、保留期 | `--table`、`--env` | +| `+db-audit-enable` | 给某表开启行级变更审计 | `--table`、`--retention`、`--env` | +| `+db-audit-disable` | 关闭某表的行级审计 | `--table`、`--env` | +| `+db-audit-list` | 列出表的行级变更事件(增删改追溯) | `--table`(可重复)、`--since`/`--until`、`--env` | +| `+db-env-diff` | 预览开发环境待发布到线上的结构变更 | `--app-id` | +| `+db-env-migrate` | 把开发环境的结构变更发布到线上(高危) | `--app-id`、`--yes` | +| `+db-recovery-diff` | 预览把库恢复到某时间点会带来的变更 | `--target` | +| `+db-recovery-apply` | 把库恢复到某个时间点、覆盖当前数据(高危) | `--target`、`--yes` | +| `+db-quota-get` | 查数据库存储用量 | `--env` | + +## 约定(先读) + +- **环境 `--env dev|online`(默认 online)**:看表、看结构、数据导入导出、变更追溯、审计、配额、初始化都按环境区分,写操作建议先在 `dev` 验。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--env`。 +- **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。 +- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 +- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。 + +## 各命令 + +### 表与结构 + +**`+db-table-list`**:列出某环境的数据表。分页 `--page-size`(默认 20)/ `--page-token`(上一页 cursor)。每项给表名、描述、估算行数、大小、列数;要完整列定义 / 索引 / 约束用 `+db-table-get`。只知道业务对象名时,先用它定位可能的表名。 + +```bash +lark-cli apps +db-table-list --app-id app_xxx +lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 +``` + +**`+db-table-get`**:看单张表的结构。默认 JSON 给结构化的字段 / 索引 / 约束 / 估算行数 / 大小;`--format pretty` 直接输出建表 DDL 文本(给用户看建表语句或做迁移参照时用)。 + +```bash +lark-cli apps +db-table-get --app-id app_xxx --table orders +lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty +``` + +### 多环境数据库(初始化 + 发布) + +**`+db-env-create`(高危)**:把存量单库应用初始化为 dev/online 两套库,不可逆,必须带 `--yes`。`--env` 目前只支持 `dev`(默认 `dev`);`--sync-data` 把现有 online 数据复制到新环境(不传则不复制)。注意:`+create --app-type full_stack` 新建的应用通常已自带多环境,重复初始化会返回「已初始化」(`Multi-env is already initialized`)——转述状态即可,别重复初始化。 + +```bash +lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run +lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes +``` + +**`+db-env-diff`**:预览开发环境里待发布到线上的表结构变更,不落地。发布前先看这个。无待发布变更时明确返回「无变更」。 + +**`+db-env-migrate`(高危)**:把开发环境的结构变更正式发布到线上,不可逆,必须带 `--yes`,返回实际发布的变更条数。发布是异步的,命令会等到完成再返回结果。 + +> 预览与发布同一端点,故 `+db-env-diff` 也需 `spark:app:write` scope(不是纯只读权限)。 + +```bash +lark-cli apps +db-env-diff --app-id app_xxx +lark-cli apps +db-env-migrate --app-id app_xxx --yes +``` + +### 数据导入导出 + +**`+db-data-export`**:把一张表导出到本地文件。导出格式跟随 `--output` 的扩展名——`.csv` / `.json` / `.sql`,缺省按 `<表名>.csv` 落在当前目录。两道体量约束: + +- `--limit`(1..5000,默认 5000)是**行数上限守卫**:表的行数超过它会被整体拒掉(不是「只导前 N 行」); +- 导出产物 >1 MB 也会被拒。 + +超大表别硬导:先用 `+db-execute` 加 `WHERE` / `LIMIT` 缩小范围、分批导。 + +```bash +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.csv +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.json --env dev +``` + +**`+db-data-import`(高危)**:把本地 csv/json 文件的数据导进表。文件需是 `.csv`/`.json`、≤1 MB,必须带 `--yes`。目标表缺省取文件名去掉**最后一个**扩展名(如 `orders.csv`→`orders`,`orders.2026.csv`→`orders.2026`);文件名带点号时建议显式传 `--table` 以免落到意外的表名。 + +```bash +lark-cli apps +db-data-import --app-id app_xxx --table orders --file ./orders.csv --env dev --yes +``` + +### 变更追溯与审计 + +**`+db-changelog-list`**:查表结构变更(DDL)历史——谁、什么时候、改了哪张表、做了什么。可按 `--table` 过滤、按 `--change-id` 精确定位某条、用 `--since`/`--until` 圈时间区间,分页 `--page-size`/`--page-token`。 + +```bash +lark-cli apps +db-changelog-list --app-id app_xxx --table orders --since 7d +``` + +**`+db-audit-status`**:看审计开关状态。给 `--table` 看单表,不给则列出所有已配置的表(开没开、保留期)。 + +**`+db-audit-enable` / `+db-audit-disable`**:开 / 关某张表的行级变更审计。`--retention` 设保留期,取值 `7d`/`30d`/`180d`/`360d`/`forever`(默认 `7d`)。不要对已经开启审计的表重复 enable——不确定就先用 `+db-audit-status` 查。 + +```bash +lark-cli apps +db-audit-enable --app-id app_xxx --table orders --retention 30d +lark-cli apps +db-audit-disable --app-id app_xxx --table orders +``` + +**`+db-audit-list`**:列出表的行级变更事件(INSERT/UPDATE/DELETE 的前后值与操作人)。`--table` 必填、可重复传多张表;`--since`/`--until` 圈时间。 +- **多表查询**:会先帮用户把不存在、或没开审计的表过滤掉再查,被过滤的表及原因列在结果的 `skipped` 里——据此告诉用户哪些表没纳入及为什么。 +- **单表查询**:不预过滤,表不存在 / 未开审计会直接报错(按 `error.hint` 转述给用户,引导先 `+db-audit-enable`)。 + +```bash +lark-cli apps +db-audit-list --app-id app_xxx --table orders --since 24h +lark-cli apps +db-audit-list --app-id app_xxx --table orders --table users +``` + +### 时间点恢复(PITR) + +**`+db-recovery-diff`**:预览把库恢复到 `--target` 时间点会带来哪些变更(受影响的表、行数、预计耗时),不落地。同样需 `spark:app:write` scope。 + +**`+db-recovery-apply`(高危)**:把库恢复到某个时间点,**会覆盖当前数据**,不可逆,必须带 `--yes`。 + +- 可恢复窗口最长 **7 天**,且不早于**最近一次 `+db-env-migrate`**;超出窗口的目标会被拒。 +- 目标时间点与当前库一致时返回 `no_changes`(空操作),不算失败。 +- 动手前务必先 `+db-recovery-diff` 给用户确认。 + +```bash +lark-cli apps +db-recovery-diff --app-id app_xxx --target 2h +lark-cli apps +db-recovery-apply --app-id app_xxx --target 2026-04-15T10:00:00Z --yes +``` + +### 配额 + +**`+db-quota-get`**:查数据库存储用量(已用量、表数、视图数;配额接入后还会给总配额与使用率)。 + +```bash +lark-cli apps +db-quota-get --app-id app_xxx --env dev +``` + +## 时间格式(`--since` / `--until` / `--target`) + +按用户口语自然传入即可,支持: +- 相对时间 `7d` / `2h` / `30s`(从现在往前推) +- 日期 `2026-04-15` +- 日期时间 `2026-04-15T10:00:00` +- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` + +## Agent 规则 + +- 用户说「本地 / 开发库 / 调试库」优先 `--env dev`,线上排查用 `--env online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。 +- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。 +- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--env dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。 +- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。 +- `+db-audit-list` 多表查询时,把结果里 `skipped` 的表(不存在 / 未开审计)连同原因一并向用户说明,不要让用户以为这些表「没有变更」。 +- 恢复是覆盖式且不可逆:`+db-recovery-apply` 前必须先 `+db-recovery-diff`,并明确告知用户会覆盖当前数据。 diff --git a/skills/lark-apps/references/lark-apps-file.md b/skills/lark-apps/references/lark-apps-file.md new file mode 100644 index 000000000..829d731a9 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-file.md @@ -0,0 +1,94 @@ +# apps file 域命令(应用存储) + +管理妙搭应用的文件存储:上传 / 下载本地文件、列出与查看已存文件、生成临时分享链接、批量删除、查看用量。运行时命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要在某个妙搭应用里上传 / 下载 / 列出 / 删除文件、拿文件的临时分享链接、或看存储用量时。普通飞书云盘走 [`lark-drive`](../../lark-drive/SKILL.md);数据库里的表数据走 `+db-*`。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+file-list` | 列出文件,可按名/路径/类型/大小/上传时间过滤 | `--app-id`、过滤器、`--page-size`/`--page-token` | +| `+file-get` | 查单个文件的元数据 | `--app-id`、`--path` | +| `+file-sign` | 生成有时效的下载链接(用于分享 / 直接下载) | `--app-id`、`--path`、`--expires-in` | +| `+file-download` | 把远端文件保存到本地 | `--app-id`、`--path`、`--output` | +| `+file-upload` | 上传本地文件到应用存储 | `--app-id`、`--file` | +| `+file-delete` | 按路径批量删除文件 | `--app-id`、`--path`(可重复)、`--yes` | +| `+file-quota-get` | 查应用的文件存储用量 | `--app-id` | + +## 寻址与约定(先读) + +- **远端文件统一用 `--path` 精确寻址**(远端路径,带前导 `/`)。只知道文件名时,先用 `+file-list --name <名>` 定位拿到 `path`,再做后续操作。 +- **本地文件 / 输出路径用工作目录内的相对路径**(如 `--file ./report.pdf`、`--output ./out.png`);路径在别处时先 `cd` 过去或改成相对路径。 +- 上传只接收本地 `--file`:文件名沿用本地文件名,远端路径由平台分配、全局唯一(无需也无法手填)。 +- file 域不区分环境,没有 `--env`。 + +## 各命令 + +### +file-list +列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。返回每个文件的名称、路径、大小、类型、上传者、上传时间、下载地址。 + +```bash +lark-cli apps +file-list --app-id app_xxx +lark-cli apps +file-list --app-id app_xxx --type image/png --uploaded-since 7d +``` + +### +file-get +按 `--path` 查单个文件的元数据。路径不存在时返回明确的「文件不存在」错误。 + +```bash +lark-cli apps +file-get --app-id app_xxx --path /1858537546760216.png +``` + +### +file-sign +为指定文件生成一个**有时效的下载链接**——适合发给用户分享、或直接下载。`--expires-in` 设有效期秒数(默认 1 天,最长 30 天)。`pretty` 模式只输出链接本身,便于复制 / 管道;要把到期时间一并告诉用户时用默认 JSON 输出(含到期时间)。 + +```bash +lark-cli apps +file-sign --app-id app_xxx --path /1858537546760216.png --expires-in 3600 +``` + +### +file-download +把远端文件保存到本地。`--output` 指定保存路径,缺省时按远端文件名保存到当前目录。 + +```bash +lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --output ./logo.png +``` + +### +file-upload +上传一个本地文件。文件名沿用本地文件名,远端路径由平台分配。单文件上限 100 MB。 + +```bash +lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf +``` + +### +file-delete(高危) +按路径批量删除,`--path` 可重复传多个。删除是高危操作,必须带 `--yes`;缺省会被确认关卡拦下。**逐项返回结果**:部分文件删除失败(如某个路径不存在)不影响其余文件,整体仍算成功,失败项在结果里单独标出原因。 + +```bash +lark-cli apps +file-delete --app-id app_xxx --path /1858537546760216.png --yes +lark-cli apps +file-delete --app-id app_xxx --path /a.png --path /b.png --yes +``` + +### +file-quota-get +查应用的文件存储用量(已用量、文件数;配额接入后还会给总配额与使用率)。 + +```bash +lark-cli apps +file-quota-get --app-id app_xxx +``` + +## 时间格式(`--uploaded-since` / `--uploaded-until`) + +按用户口语自然传入即可,支持: +- 相对时间 `7d` / `2h` / `30s`(从现在往前推) +- 日期 `2026-04-15` +- 日期时间 `2026-04-15T10:00:00` +- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` + +## Agent 规则 + +- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。 +- 上传 / 下载的本地路径用工作目录内相对路径;不在当前目录就 `cd` 过去或改相对路径。 +- 用户要「分享链接 / 临时下载地址」时用 `+file-sign`,把返回的链接转述给用户。 +- 删除前判断意图:已明确要删且授权时可直接带 `--yes`;不确定删哪些时先 `+file-list` 给用户确认。批量删除部分失败不报错,按逐项结果向用户说明哪些成功、哪些没删掉及原因。