mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Compare commits
6 Commits
feat/lark-
...
fix/apps-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee8d8fd941 | ||
|
|
50fb41917e | ||
|
|
f984e16d3a | ||
|
|
d752ab9a20 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 |
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
existing := map[string]bool{}
|
existing := map[string]bool{}
|
||||||
token := ""
|
token := ""
|
||||||
for {
|
for {
|
||||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
params := map[string]interface{}{"page_size": 100}
|
||||||
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
|
|
||||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
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)
|
statusParams := map[string]interface{}{}
|
||||||
|
if env != "" {
|
||||||
|
statusParams["env"] = env
|
||||||
|
}
|
||||||
|
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
|||||||
|
|
||||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"tables": strings.Join(tables, ","),
|
"tables": strings.Join(tables, ","),
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to enable audit for", 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: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Enable table audit").
|
Desc("Enable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
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 {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||||
defer stop()
|
defer stop()
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||||
stop()
|
stop()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Disable table audit").
|
Desc("Disable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
}
|
}
|
||||||
table := strings.TrimSpace(rctx.Str("table"))
|
table := strings.TrimSpace(rctx.Str("table"))
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": false})
|
map[string]interface{}{"table": table, "enabled": false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, dbAuditSetHint)
|
return withAppsHint(err, dbAuditSetHint)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
|
|
||||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||||
params["table"] = t
|
params["table"] = t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
|
|
||||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
{Name: "table", Desc: "source table", Required: true},
|
{Name: "table", Desc: "source table", Required: true},
|
||||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "source 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDataExportPath(appID)).
|
GET(appDataExportPath(appID)).
|
||||||
Desc("Export Miaoda app table data (raw bytes)").
|
Desc("Export Miaoda app table data (raw bytes)").
|
||||||
Params(map[string]interface{}{
|
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
"table": strings.TrimSpace(rctx.Str("table")),
|
||||||
"format": format, "limit": rctx.Int("limit"),
|
"format": format, "limit": rctx.Int("limit"),
|
||||||
})
|
}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||||
|
|
||||||
|
exportQuery := larkcore.QueryParams{
|
||||||
|
"table": []string{table},
|
||||||
|
"format": []string{format},
|
||||||
|
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||||
|
}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
exportQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodGet,
|
HttpMethod: http.MethodGet,
|
||||||
ApiPath: appDataExportPath(appID),
|
ApiPath: appDataExportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{
|
QueryParams: exportQuery,
|
||||||
"env": []string{dbEnv(rctx)},
|
|
||||||
"table": []string{table},
|
|
||||||
"format": []string{format},
|
|
||||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
params := map[string]interface{}{"page_size": 1}
|
||||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
|
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", 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: "table", Desc: "target table (default: file name without extension)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appDataImportPath(appID)).
|
POST(appDataImportPath(appID)).
|
||||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
fd.AddField("file_name", fileName)
|
fd.AddField("file_name", fileName)
|
||||||
fd.AddFile("file", bytes.NewReader(content))
|
fd.AddFile("file", bytes.NewReader(content))
|
||||||
|
|
||||||
|
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
importQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodPost,
|
HttpMethod: http.MethodPost,
|
||||||
ApiPath: appDataImportPath(appID),
|
ApiPath: appDataImportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
QueryParams: importQuery,
|
||||||
Body: fd,
|
Body: fd,
|
||||||
}, larkcore.WithFileUpload())
|
}, larkcore.WithFileUpload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||||
|
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||||
|
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(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", "--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)
|
||||||
|
p := env.API[0].Params
|
||||||
|
if _, ok := p["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||||
|
}
|
||||||
|
if p["table"] != "orders" {
|
||||||
|
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||||
chdirTemp(t)
|
chdirTemp(t)
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)")
|
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
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)
|
return withAppsHint(err, dbEnvMigrateHint)
|
||||||
}
|
}
|
||||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||||
|
if from == "" {
|
||||||
|
from = previewFrom
|
||||||
|
}
|
||||||
|
if to == "" {
|
||||||
|
to = previewTo
|
||||||
|
}
|
||||||
taskID := common.GetString(submit, "task_id")
|
taskID := common.GetString(submit, "task_id")
|
||||||
applied := intFromAny(submit["changes_applied"])
|
applied := intFromAny(submit["changes_applied"])
|
||||||
if applied == 0 {
|
if applied == 0 {
|
||||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
applied = n
|
applied = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||||
|
if applied == 0 && pending > 0 {
|
||||||
|
applied = pending
|
||||||
|
}
|
||||||
stop() // clear spinner before printing the result
|
stop() // clear spinner before printing the result
|
||||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
|||||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||||
|
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||||
reg.Register(&httpmock.Stub{
|
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"}},
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||||
})
|
})
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
|||||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||||
|
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||||
reg.Register(&httpmock.Stub{
|
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"}},
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||||
})
|
})
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||||
|
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||||
|
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||||
|
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||||
|
[]string{"+db-quota-get", "--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"`
|
||||||
|
Params map[string]interface{} `json:"params"`
|
||||||
|
} `json:"api"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||||
|
a := env.API[0]
|
||||||
|
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||||
|
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||||
|
}
|
||||||
|
if _, ok := a.Params["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
|||||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||||
Input: []string{common.Stdin}},
|
Input: []string{common.Stdin}},
|
||||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
|||||||
//
|
//
|
||||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"transactional": false,
|
"transactional": false,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDbQuotaPath(appID)).
|
GET(appDbQuotaPath(appID)).
|
||||||
Desc("Get Miaoda app database storage usage").
|
Desc("Get Miaoda app database storage usage").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, appIDListHint)
|
return withAppsHint(err, appIDListHint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
|||||||
Scopes: []string{"spark:app:write"},
|
Scopes: []string{"spark:app:write"},
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{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},
|
{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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return normalizeTimeFlags(rctx, "target")
|
return normalizeTimeFlags(rctx, "target")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
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})
|
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
Scopes: []string{"spark:app:write"},
|
Scopes: []string{"spark:app:write"},
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{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},
|
{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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return normalizeTimeFlags(rctx, "target")
|
return normalizeTimeFlags(rctx, "target")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
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})
|
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
target := rctx.Str("target")
|
target := rctx.Str("target")
|
||||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||||
defer stop()
|
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 {
|
if err != nil {
|
||||||
return withAppsHint(err, dbRecoveryHint)
|
return withAppsHint(err, dbRecoveryHint)
|
||||||
}
|
}
|
||||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
}
|
}
|
||||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
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) {
|
func(d map[string]interface{}) (bool, error) {
|
||||||
switch strings.ToLower(common.GetString(d, "status")) {
|
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) {
|
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||||
defer stop()
|
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 {
|
if err != nil {
|
||||||
return nil, withAppsHint(err, dbRecoveryHint)
|
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,
|
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
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) {
|
func(d map[string]interface{}) (bool, error) {
|
||||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
|||||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||||
arr, _ := preview["changes"].([]interface{})
|
arr, _ := preview["changes"].([]interface{})
|
||||||
changes := make([]recoveryChange, 0, len(arr))
|
raw := make([]recoveryChange, 0, len(arr))
|
||||||
for _, it := range arr {
|
for _, it := range arr {
|
||||||
m, ok := it.(map[string]interface{})
|
m, ok := it.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
changes = append(changes, recoveryChange{
|
raw = append(raw, recoveryChange{
|
||||||
Table: common.GetString(m, "table"),
|
Table: common.GetString(m, "table"),
|
||||||
Inserted: m["inserted"],
|
Inserted: m["inserted"],
|
||||||
Deleted: m["deleted"],
|
Deleted: m["deleted"],
|
||||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
|||||||
DroppedAt: common.GetString(m, "dropped_at"),
|
DroppedAt: common.GetString(m, "dropped_at"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
tablesAffected := intFromAny(preview["tables_affected"])
|
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||||
if tablesAffected == 0 {
|
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||||
tablesAffected = len(changes)
|
// 两行 + 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"])
|
est := intFromAny(preview["estimated_seconds"])
|
||||||
if est == 0 {
|
if est == 0 {
|
||||||
est = 30 // PRD 兜底
|
est = 30 // PRD 兜底
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"target": target, "tables_affected": tablesAffected,
|
"target": target, "tables_affected": len(seen),
|
||||||
"changes": changes, "estimated_seconds": est,
|
"changes": changes, "estimated_seconds": est,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "app id", Required: true},
|
{Name: "app-id", Desc: "app id", Required: true},
|
||||||
{Name: "table", Desc: "table name", Required: true},
|
{Name: "table", Desc: "table name", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
|||||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if rctx.Format == "pretty" {
|
if rctx.Format == "pretty" {
|
||||||
params["format"] = "ddl"
|
params["format"] = "ddl"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "app id", Required: true},
|
{Name: "app-id", Desc: "app id", Required: true},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, 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 {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return f, true
|
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:
|
case nil:
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
|||||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||||
{"nil", nil, 0, false},
|
{"nil", nil, 0, false},
|
||||||
{"unsupported string", "x", 0, false},
|
{"non-numeric string", "x", 0, false},
|
||||||
|
{"numeric string", "13.5", 13.5, true},
|
||||||
|
{"numeric string int", "2", 2, true},
|
||||||
|
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||||
|
{"empty string", "", 0, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
|||||||
return rctx.Str("environment")
|
return rctx.Str("environment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbEnvParams 把 env 并入 params:仅当显式指定了环境(非空)才带 env 键;未指定(空)时
|
||||||
|
// 省略该键,由服务端按应用多环境状态自动选分支(多环境→dev,单环境→online)。与家族对
|
||||||
|
// 空可选参数的 omit-empty 约定一致——不发空串,wire 上真正不带 env。原样返回同一个 map 便于链式。
|
||||||
|
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||||
if rctx.Changed("env") {
|
if rctx.Changed("env") {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||||
|
|
||||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||||
|
|||||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
dr := common.NewDryRunAPI()
|
return common.NewDryRunAPI().
|
||||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
|
||||||
}
|
|
||||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
|
||||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||||
|
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||||
|
|
||||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"minute_token": minuteToken,
|
"minute_token": minuteToken,
|
||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
}
|
}
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
out["from_speaker_id"] = fromSpeakerID
|
out["from_speaker_id"] = fromSpeakerID
|
||||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
|
||||||
out["from_speaker_input"] = fromSpeakerInput
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
out["from_user_id"] = fromUserID
|
out["from_user_id"] = fromUserID
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||||
if fromSpeakerInput != "" {
|
|
||||||
return fromSpeakerInput
|
|
||||||
}
|
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
return fromSpeakerID
|
return fromSpeakerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
||||||
warmTokenCache(t)
|
|
||||||
|
|
||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
|
||||||
"+speaker-replace",
|
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run", "--as", "user",
|
|
||||||
}, f, stdout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
if !strings.Contains(out, "GET") {
|
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
|
||||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "PUT") {
|
|
||||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "ou_new_speaker") {
|
|
||||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
warmTokenCache(t)
|
warmTokenCache(t)
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||||
Method: http.MethodGet,
|
// straight through without a second speakerlist call. If the code still
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"speakers": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
|
||||||
"name": "说话人1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||||
"+speaker-replace",
|
"+speaker-replace",
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||||
"--from-speaker-id", "说话人1",
|
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||||
"--to-user-id", "ou_new_speaker",
|
"--to-user-id", "ou_new_speaker",
|
||||||
"--format", "json", "--as", "user",
|
"--format", "json", "--as", "user",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
FromSpeakerID string `json:"from_speaker_id"`
|
||||||
FromSpeakerInput string `json:"from_speaker_input"`
|
ToUserID string `json:"to_user_id"`
|
||||||
FromSpeakerID string `json:"from_speaker_id"`
|
|
||||||
ToUserID string `json:"to_user_id"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
t.Fatalf("unmarshal stdout: %v", err)
|
t.Fatalf("unmarshal stdout: %v", err)
|
||||||
}
|
}
|
||||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
|
||||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
|
||||||
}
|
|
||||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||||
}
|
}
|
||||||
|
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||||
|
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "GET") {
|
if strings.Contains(out, "/transcript/speakerlist") {
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "PUT") {
|
||||||
|
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
type minuteSpeaker struct {
|
|
||||||
SpeakerID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
|
||||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if data == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := common.GetSlice(data, "speakers")
|
|
||||||
speakers := make([]minuteSpeaker, 0, len(items))
|
|
||||||
for _, raw := range items {
|
|
||||||
item, _ := raw.(map[string]interface{})
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
|
||||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
|
||||||
}
|
|
||||||
return speakers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
var matches []minuteSpeaker
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.Name == name {
|
|
||||||
matches = append(matches, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch len(matches) {
|
|
||||||
case 0:
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
|
||||||
"no speaker named %q in minute transcript", name).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
|
||||||
case 1:
|
|
||||||
return matches[0].SpeakerID, nil
|
|
||||||
default:
|
|
||||||
ids := make([]string, len(matches))
|
|
||||||
for i, m := range matches {
|
|
||||||
ids[i] = m.SpeakerID
|
|
||||||
}
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
|
||||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
|
||||||
// The input may already be an opaque speaker_id, or a display name that requires
|
|
||||||
// an internal speaker-list fetch.
|
|
||||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.SpeakerID == input {
|
|
||||||
return input, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolveSpeakerIDByName(speakers, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
|
||||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
|
||||||
if fromUserID != "" {
|
|
||||||
return "", fromUserID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
|
||||||
return fromSpeakerID, "", err
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
|
||||||
speakers := []minuteSpeaker{
|
|
||||||
{SpeakerID: "id_a", Name: "Alice"},
|
|
||||||
{SpeakerID: "id_b", Name: "Bob"},
|
|
||||||
{SpeakerID: "id_c", Name: "Alice"},
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
|
||||||
if err != nil || id != "id_b" {
|
|
||||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected not found error")
|
|
||||||
}
|
|
||||||
var ve *errs.ValidationError
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
|
||||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected duplicate name error")
|
|
||||||
}
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
|
||||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
|
||||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||||
- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
- `--environment` 枚举:`dev` / `online`,**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`)**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||||
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
## 约定(先读)
|
## 约定(先读)
|
||||||
|
|
||||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `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` 过去或改成相对路径。
|
- **本地文件 / `--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` 看清影响。
|
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||||
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
|||||||
|
|
||||||
## Agent 规则
|
## Agent 规则
|
||||||
|
|
||||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`。**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
|
||||||
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
|
- 看表用 `+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` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
||||||
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
> 错误:`lark-cli drive +search 方案`
|
> 错误:`lark-cli drive +search 方案`
|
||||||
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
||||||
>
|
>
|
||||||
|
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||||
|
>
|
||||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||||
|
|
||||||
### 自然语言 → 命令映射速查
|
### 自然语言 → 命令映射速查
|
||||||
@@ -101,7 +103,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览 |
|
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
|
||||||
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
||||||
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
||||||
| `--format` | 否 | `json`(默认)/ `pretty` |
|
| `--format` | 否 | `json`(默认)/ `pretty` |
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \
|
|||||||
|
|
||||||
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
||||||
|
|
||||||
|
`+speaker-replace` **不会**自己请求 speakerlist:`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist,务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found)。
|
||||||
|
|
||||||
### 2. 新说话人必须是 open_id
|
### 2. 新说话人必须是 open_id
|
||||||
|
|
||||||
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URL,CLI 永远走 DBA 模式
|
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URL,CLI 永远走 DBA 模式
|
||||||
// (?transactional=false),sql body 由 --sql 透传,默认 env=dev。
|
// (?transactional=false),sql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)。
|
||||||
func TestAppsDBExecuteDryRun(t *testing.T) {
|
func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||||
setAppsDryRunEnv(t)
|
setAppsDryRunEnv(t)
|
||||||
|
|
||||||
t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) {
|
t.Run("DefaultEnvUnsetAndTransactionalFalse", func(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
|
|||||||
"CLI is DBA mode → must send transactional=false in query")
|
"CLI is DBA mode → must send transactional=false in query")
|
||||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
|
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
|
||||||
"transactional should be in query, not body")
|
"transactional should be in query, not body")
|
||||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(),
|
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
|
||||||
"default env must be dev (not production)")
|
"default: no --environment → env key must be omitted (server picks workspace default branch)")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("OnlineEnvSwitch", func(t *testing.T) {
|
t.Run("OnlineEnvSwitch", func(t *testing.T) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
func TestAppsDBTableListDryRun(t *testing.T) {
|
func TestAppsDBTableListDryRun(t *testing.T) {
|
||||||
setAppsDryRunEnv(t)
|
setAppsDryRunEnv(t)
|
||||||
|
|
||||||
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
|
t.Run("DefaultsToNoEnvAndPageSize20", func(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
@@ -32,7 +32,8 @@ func TestAppsDBTableListDryRun(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
|
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
|
||||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
|
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
|
||||||
|
"default: no --environment → env key must be omitted (server picks workspace default branch)")
|
||||||
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
|
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
|
||||||
"empty page_token must be omitted")
|
"empty page_token must be omitted")
|
||||||
|
|||||||
@@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
|
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
|
||||||
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
|
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
|
||||||
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
|
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
|
||||||
}
|
assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output)
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
setDryRunConfigEnv(t)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
|
||||||
Args: []string{
|
|
||||||
"minutes", "+speaker-replace",
|
|
||||||
"--minute-token", "obcnexampleminute",
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run",
|
|
||||||
},
|
|
||||||
DefaultAs: "user",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
result.AssertExitCode(t, 0)
|
|
||||||
|
|
||||||
output := result.Stdout
|
|
||||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output)
|
|
||||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output)
|
|
||||||
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
|
|
||||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user