mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 01:51:27 +08:00
Make --environment the only accepted db environment flag across the db commands (execute, table-list/get, env-create, data export/import, changelog, audit status/enable/disable/list, quota). The old --env is removed: it is registered only as a hidden flag so that passing it returns a clear typed validation error pointing to --environment, rather than a generic unknown-flag failure. Update the lark-apps db references accordingly.
266 lines
9.8 KiB
Go
266 lines
9.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/validate"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// ── db 环境 flag:--environment 是唯一受理名;旧名 --env 已移除 ──
|
||
//
|
||
// 硬改名:标准名 --environment(带默认/枚举)正常注册并受理;旧名 --env 仅注册为隐藏 flag,
|
||
// 目的是「传了能被识别并给出清晰报错」而非继续受理——一旦显式传 --env,在 Validate 阶段直接
|
||
// 返回 validation 错、指向 --environment。所有 DryRun/Execute 经 dbEnv() 只读 --environment。
|
||
|
||
// dbEnvFlags 返回环境 flag 对,供各 db 命令 append 进自己的 Flags。
|
||
func dbEnvFlags(def string, enum []string, desc string) []common.Flag {
|
||
return []common.Flag{
|
||
{Name: "environment", Default: def, Enum: enum, Desc: desc},
|
||
{Name: "env", Hidden: true, Desc: "removed: use --environment"},
|
||
}
|
||
}
|
||
|
||
// dbEnv 取环境值:只认标准 --environment(含其默认值);旧名 --env 不再受理(见 rejectLegacyEnvFlag)。
|
||
func dbEnv(rctx *common.RuntimeContext) string {
|
||
return rctx.Str("environment")
|
||
}
|
||
|
||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||
if rctx.Changed("env") {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--env is no longer supported; use --environment instead").WithParam("--env")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 {
|
||
// async 任务多半还在服务端推进,poll 超时是可重试的——标 retryable 让 agent 重新轮询而非放弃。
|
||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTimeout, "timed out waiting for completion after %s", maxWait).WithRetryable()
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "cancelled while waiting").WithCause(ctx.Err())
|
||
case <-time.After(interval):
|
||
}
|
||
}
|
||
}
|
||
|
||
// URL helpers for the db CLI commands.
|
||
|
||
// appTablesPath 返回 app db 表列表 URL(复用存量「获取数据表列表」接口)。
|
||
func appTablesPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/tables", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appTablePath 返回单个 app db 表详情 URL(复用存量「获取数据表详细信息」接口)。
|
||
func appTablePath(appID, table string) string {
|
||
return appTablesPath(appID) + "/" + validate.EncodePathSegment(table)
|
||
}
|
||
|
||
// appSQLPath 返回 app db SQL 执行 URL(复用存量「执行 SQL」接口)。
|
||
func appSQLPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/sql_commands", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appDbEnvCreatePath 返回 app db 环境创建 URL(服务端接口名仍为 db_dev_init)。
|
||
func appDbEnvCreatePath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// ── 多环境发布(env diff/migrate)/ 数据恢复(recovery)/ 配额 路由 ──
|
||
|
||
// appEnvMigratePath 返回 dev→online 发布(预览/落地共用)URL:db/env_migrate。
|
||
func appEnvMigratePath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appEnvMigrateStatusPath 返回发布异步任务状态查询 URL:db/env_migrate_status。
|
||
func appEnvMigrateStatusPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appRecoveryPath 返回 PITR 数据恢复(预览/落地共用)URL:db/env_recovery。
|
||
func appRecoveryPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appRecoveryDiffStatusPath 返回恢复预览(diff)异步状态查询 URL:db/env_recovery_diff_status。
|
||
func appRecoveryDiffStatusPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/env_recovery_diff_status", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appRecoveryApplyStatusPath 返回恢复落地异步状态查询 URL:db/env_recovery_apply_status。
|
||
func appRecoveryApplyStatusPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/env_recovery_apply_status", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appDbQuotaPath 返回 db 配额查询 URL:db/quota。
|
||
func appDbQuotaPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// ── 变更追溯(changelog / audit)路由 ──
|
||
|
||
// appChangelogListPath 返回 DDL 变更记录列表 URL:db/changelog_list。
|
||
func appChangelogListPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appAuditStatusPath 返回表审计开关状态查询 URL:db/audit_status。
|
||
func appAuditStatusPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appAuditSetPath 返回表审计开关设置 URL:db/audit_set。
|
||
func appAuditSetPath(appID string) string {
|
||
return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID))
|
||
}
|
||
|
||
// appAuditListPath 返回行级审计事件列表 URL:db/audit_list。
|
||
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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv, .json or .sql)", raw)
|
||
}
|
||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "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)
|
||
if id == "" {
|
||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||
}
|
||
return id, nil
|
||
}
|