diff --git a/shortcuts/apps/apps_db_env_migrate.go b/shortcuts/apps/apps_db_env_migrate.go index bea63a6a6..363463c28 100644 --- a/shortcuts/apps/apps_db_env_migrate.go +++ b/shortcuts/apps/apps_db_env_migrate.go @@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{ if err != nil { return err } + // 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经 + // dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。 + // 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断, + // 交由下面真实 apply 统一报同样的业务错。 + pending := 0 + var previewFrom, previewTo string + if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil { + pending = len(projectMigrationChanges(preview["changes"])) + previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to") + } stop := rctx.StartSpinner("Applying migration (dev → online)") defer stop() submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false}) @@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{ return withAppsHint(err, dbEnvMigrateHint) } from, to := common.GetString(submit, "from"), common.GetString(submit, "to") + if from == "" { + from = previewFrom + } + if to == "" { + to = previewTo + } taskID := common.GetString(submit, "task_id") applied := intFromAny(submit["changes_applied"]) if applied == 0 { @@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{ applied = n } } + // 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。 + if applied == 0 && pending > 0 { + applied = pending + } stop() // clear spinner before printing the result out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied} rctx.OutFormat(out, nil, func(w io.Writer) { diff --git a/shortcuts/apps/apps_db_env_recovery_quota_test.go b/shortcuts/apps/apps_db_env_recovery_quota_test.go index a2dee10b1..30edad30d 100644 --- a/shortcuts/apps/apps_db_env_recovery_quota_test.go +++ b/shortcuts/apps/apps_db_env_recovery_quota_test.go @@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) { // 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。 func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) + // Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的 + // diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。 reg.Register(&httpmock.Stub{ - Method: "POST", URL: dbEnvMigrateURL, + Method: "POST", URL: dbEnvMigrateURL, Reusable: true, Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, }) reg.Register(&httpmock.Stub{ @@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) { // TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。 func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) + // Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的 + // diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。 reg.Register(&httpmock.Stub{ - Method: "POST", URL: dbEnvMigrateURL, + Method: "POST", URL: dbEnvMigrateURL, Reusable: true, Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, }) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/apps/apps_db_recovery.go b/shortcuts/apps/apps_db_recovery.go index 181a01eec..83a4d5066 100644 --- a/shortcuts/apps/apps_db_recovery.go +++ b/shortcuts/apps/apps_db_recovery.go @@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{ Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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}, - }, + }, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); 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"). + Params(dbEnvParams(rctx, map[string]interface{}{})). Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{ Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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}, - }, + }, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); 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"). + Params(dbEnvParams(rctx, map[string]interface{}{})). Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{ target := rctx.Str("target") stop := rctx.StartSpinner("Restoring database (target: " + target + ")") defer stop() - submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false}) + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false}) if err != nil { return withAppsHint(err, dbRecoveryHint) } @@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{ } final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute, func() (map[string]interface{}, error) { - return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil) + return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil) }, func(d map[string]interface{}) (bool, error) { switch strings.ToLower(common.GetString(d, "status")) { @@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) { stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")") defer stop() - submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true}) + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true}) if err != nil { return nil, withAppsHint(err, dbRecoveryHint) } @@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[ } return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute, func() (map[string]interface{}, error) { - return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil) + return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil) }, func(d map[string]interface{}) (bool, error) { switch strings.ToLower(common.GetString(d, "preview_status")) { @@ -195,13 +203,13 @@ type recoveryChange struct { // 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)) + raw := make([]recoveryChange, 0, len(arr)) for _, it := range arr { m, ok := it.(map[string]interface{}) if !ok { continue } - changes = append(changes, recoveryChange{ + raw = append(raw, recoveryChange{ Table: common.GetString(m, "table"), Inserted: m["inserted"], Deleted: m["deleted"], @@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin DroppedAt: common.GetString(m, "dropped_at"), }) } - tablesAffected := intFromAny(preview["tables_affected"]) - if tablesAffected == 0 { - tablesAffected = len(changes) + // 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。 + // schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表 + // 两行 + tables_affected 翻倍。 + hasSchema := map[string]bool{} + for _, c := range raw { + if c.Action != "" { + hasSchema[c.Table] = true + } + } + changes := make([]recoveryChange, 0, len(raw)) + for _, c := range raw { + if c.Action == "" && hasSchema[c.Table] { + continue + } + changes = append(changes, c) + } + // tables_affected 按去重后的不同表数计(而非变更条数)。 + seen := map[string]bool{} + for _, c := range changes { + seen[c.Table] = true } est := intFromAny(preview["estimated_seconds"]) if est == 0 { est = 30 // PRD 兜底 } return map[string]interface{}{ - "target": target, "tables_affected": tablesAffected, + "target": target, "tables_affected": len(seen), "changes": changes, "estimated_seconds": est, } } diff --git a/shortcuts/apps/apps_db_table_list.go b/shortcuts/apps/apps_db_table_list.go index e5bcded2e..a117c1879 100644 --- a/shortcuts/apps/apps_db_table_list.go +++ b/shortcuts/apps/apps_db_table_list.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "strconv" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -285,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) { return 0, false } return f, true + case string: + // 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。 + s := strings.TrimSpace(v) + if s == "" { + return 0, false + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return f, true case nil: return 0, false } diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md index 88f6e40c1..f4a97dc05 100644 --- a/skills/lark-apps/references/lark-apps-db.md +++ b/skills/lark-apps/references/lark-apps-db.md @@ -28,7 +28,7 @@ ## 约定(先读) -- **环境 `--environment dev|online`(可省略)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`、未开多环境的走 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式传 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。 +- **环境 `--environment dev|online`(可省略)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`、未开多环境的走 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式传 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义,**没有** `--environment`。 - **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。 - **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 - **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。