Files
dingtalk-workspace-cli/internal/helpers/aitable_export_import.go
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00

397 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/asynctask"
"github.com/spf13/cobra"
)
// PR-D:aitable export/import 静态命令覆盖
//
// MSE 已经注册了 export_data / import_data / prepare_import_upload 三个 tool
// (动态发现命令)。本文件用 preferLegacyLeaf 固定命令 surface,避免动态
// 发现层和 Wukong 的稳定命令口径漂移。
//
// TRANSITIONAL: 等 mse 把 asyncBehavior 标注加入 toolOverrides 后,
// 这套 helper 行为可由动态发现层统一处理,本文件可整体删除。
// 工单:plan/mse-yuyuan-patch.md(待后续补充 asyncBehavior 规范)
// ── aitable export data ─────────────────────────────────────
func newAitableExportDataCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "data",
Short: i18n.T("导出数据"),
Long: i18n.T(`导出 AI 表格数据的统一入口。
不传 --task-id 时,根据 --scope / --format 创建新的导出任务,并同步等待结果;
若在等待窗口内完成,则直接返回 downloadUrl 和 fileName。
传入 --task-id 时,继续等待该任务,不会重新创建。
scope 可选值:all(整个 Base)、table(指定数据表)、view(指定视图)。
format 可选值:excel、attachment、excel_and_attachment、excel_with_inline_images。`),
Example: ` dws aitable export data --base-id BASE_ID --scope all --format excel
dws aitable export data --base-id BASE_ID --scope table --table-id TABLE_ID --format excel
dws aitable export data --base-id BASE_ID --scope view --table-id TABLE_ID --view-id VIEW_ID --format excel
dws aitable export data --base-id BASE_ID --task-id TASK_ID
# 查询 baseId: dws aitable base list`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableExportData(cmd, runner)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
addAitableHiddenStringFlag(cmd, "base", "--base-id 的兼容别名")
cmd.Flags().String("scope", "", i18n.T("导出范围:all(整个 Base)、table(指定数据表)、view(指定视图)"))
cmd.Flags().String("format", "", i18n.T("导出格式:excel、attachment、excel_and_attachment、excel_with_inline_images"))
cmd.Flags().String("task-id", "", i18n.T("已有导出任务 ID,传入后继续等待(忽略 scope/format/table-id/view-id)"))
cmd.Flags().String("table-id", "", i18n.T("Table ID,scope=table 或 scope=view 时必填"))
cmd.Flags().String("view-id", "", i18n.T("View ID,scope=view 时必填"))
cmd.Flags().Int("timeout-ms", 0, i18n.T("单次等待超时(毫秒),默认 30000,最大 30000"))
return cmd
}
func runAitableExportData(cmd *cobra.Command, runner executor.Runner) error {
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
taskID, _ := cmd.Flags().GetString("task-id")
scope, _ := cmd.Flags().GetString("scope")
format, _ := cmd.Flags().GetString("format")
tableID, _ := cmd.Flags().GetString("table-id")
viewID, _ := cmd.Flags().GetString("view-id")
timeoutMS, _ := cmd.Flags().GetInt("timeout-ms")
params := map[string]any{
"baseId": baseID,
}
if taskID != "" {
params["taskId"] = taskID
} else {
if strings.TrimSpace(scope) == "" {
return apperrors.NewValidation("--scope is required")
}
if strings.TrimSpace(format) == "" {
return apperrors.NewValidation("--format is required")
}
params["scope"] = scope
params["format"] = format
}
if tableID != "" {
params["tableId"] = tableID
}
if viewID != "" {
params["viewId"] = viewID
}
if timeoutMS > 0 {
params["timeoutMs"] = timeoutMS
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "export_data", params,
))
}
return runAitableTool(cmd, runner, "export_data", params)
}
// ── aitable import upload ───────────────────────────────────
func newAitableImportUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: i18n.T("准备导入文件上传"),
Long: i18n.T(`为导入任务申请 OSS 直传地址。返回 uploadUrl 和 importId。
客户端应通过 HTTP PUT 将原始文件字节流上传至 uploadUrl。
上传完成后将 importId 传入 import data 即可触发导入。
完整流程:
1. dws aitable import upload --base-id BASE_ID --file-name data.xlsx --file-size 204800
→ 获取 uploadUrl 和 importId
2. curl -X PUT "<uploadUrl>" --data-binary @data.xlsx
→ 上传文件到 OSS
3. dws aitable import data --import-id <importId>
→ 触发导入`),
Example: ` dws aitable import upload --base-id BASE_ID --file-name data.xlsx --file-size 204800
# 查询 baseId: dws aitable base list`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableImportUpload(cmd, runner)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
addAitableHiddenStringFlag(cmd, "base", "--base-id 的兼容别名")
cmd.Flags().String("file-name", "", i18n.T("文件名,须带扩展名,如 data.xlsx (必填)"))
cmd.Flags().Int64("file-size", 0, i18n.T("文件大小(字节数)(必填)"))
return cmd
}
func runAitableImportUpload(cmd *cobra.Command, runner executor.Runner) error {
fileName, err := aitableRequiredFlag(cmd, "file-name")
if err != nil {
return err
}
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
fileSize, _ := cmd.Flags().GetInt64("file-size")
params := map[string]any{
"baseId": baseID,
"fileName": fileName,
}
if fileSize > 0 {
params["fileSize"] = fileSize
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "prepare_import_upload", params,
))
}
return runAitableTool(cmd, runner, "prepare_import_upload", params)
}
// ── aitable import data ─────────────────────────────────────
func newAitableImportDataCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "data",
Short: i18n.T("导入数据"),
Long: i18n.T(`将已通过 import upload 上传完成的文件导入 AI 表格。
支持两种模式:
1. 新建表导入(默认):不传 --table-id,每个 Sheet 会新建为独立的数据表
2. 追加导入:传入 --table-id,数据将作为新行追加到该已有表中
工具内部会等待导入完成,大多数情况下一次调用即可拿到最终结果。
若在 timeout 内未完成,再次传入相同 importId 继续等待,无需重新提交任务。
追加导入时的注意事项:
- 系统按列名自动匹配字段,源文件列名须与目标表字段名一致
- 若需自定义映射关系,使用 --field-mapping 指定(key=目标表字段名,value=源文件列名)
- 多 Sheet 文件默认使用第一个 Sheet,可通过 --src-sheet-name 指定`),
Example: ` # 新建表导入
dws aitable import data --import-id IMPORT_ID
# 追加到已有表
dws aitable import data --import-id IMPORT_ID --table-id TABLE_ID
# 指定表头行和源 Sheet
dws aitable import data --import-id IMPORT_ID --table-id TABLE_ID --header-row 2 --src-sheet-name "Sheet1"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableImportData(cmd, runner)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("import-id", "", i18n.T("prepare_import_upload 返回的 importId (必填)"))
cmd.Flags().String("table-id", "", i18n.T("目标数据表 ID。传入后数据将作为新行追加到该表中;不传则默认新建表导入"))
cmd.Flags().Int("timeout", 0, i18n.T("最长等待时间(秒),默认且推荐使用最大值 30"))
cmd.Flags().Int("header-row", 0, i18n.T("表头所在行号(从 1 开始),数据从 headerRow 的下一行开始读取。不传则自动识别表头行"))
cmd.Flags().String("src-sheet-name", "", i18n.T("源文件中的 Sheet 名称。多 Sheet 文件时指定从哪个 Sheet 导入数据。不传则默认使用第一个 Sheet"))
cmd.Flags().String("field-mapping", "", i18n.T("字段映射关系 JSON 对象。key 为目标表的字段名,value 为源文件中的列名。不传则按列名自动匹配"))
return cmd
}
func runAitableImportData(cmd *cobra.Command, runner executor.Runner) error {
importID, err := aitableRequiredFlag(cmd, "import-id")
if err != nil {
return err
}
tableID, _ := cmd.Flags().GetString("table-id")
fieldMapping, _ := cmd.Flags().GetString("field-mapping")
headerRow, _ := cmd.Flags().GetInt("header-row")
srcSheetName, _ := cmd.Flags().GetString("src-sheet-name")
timeoutSec, _ := cmd.Flags().GetInt("timeout")
importParams := map[string]any{
"importId": importID,
}
if tableID != "" {
importParams["tableId"] = tableID
}
if timeoutSec > 0 {
importParams["timeout"] = timeoutSec
}
if headerRow > 0 {
importParams["headerRow"] = headerRow
}
if strings.TrimSpace(srcSheetName) != "" {
importParams["srcSheetName"] = strings.TrimSpace(srcSheetName)
}
if fieldMapping != "" {
fieldMappingValue, err := parseAitableStringMap(fieldMapping, "field-mapping")
if err != nil {
return err
}
importParams["fieldMapping"] = fieldMappingValue
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "import_data", importParams,
))
}
return runAitableTool(cmd, runner, "import_data", importParams)
}
func parseAitableStringMap(raw, flagName string) (map[string]string, error) {
var parsed map[string]string
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON object with string values", flagName))
}
if parsed == nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON object with string values", flagName))
}
return parsed, nil
}
// unwrapAitableResp 处理 MCP/runtime 常见响应包装层次。
func unwrapAitableResp(resp map[string]any) map[string]any {
if resp == nil {
return map[string]any{}
}
preserved := map[string]any{}
for depth := 0; depth < 8; depth++ {
preserveAitableWrapperFields(preserved, resp)
if content, ok := resp["content"].(map[string]any); ok && len(content) > 0 {
resp = content
continue
}
if data, ok := resp["data"].(map[string]any); ok && len(data) > 0 {
resp = data
continue
}
if result, ok := resp["result"].(map[string]any); ok && len(result) > 0 {
resp = result
continue
}
if raw, ok := resp["result"].(string); ok && strings.TrimSpace(raw) != "" {
var parsed map[string]any
if json.Unmarshal([]byte(raw), &parsed) == nil && len(parsed) > 0 {
resp = parsed
continue
}
}
break
}
out := copyAitableMap(resp)
for k, v := range preserved {
if k == "status" || k == "state" {
if s, ok := v.(string); ok && normalizeAsyncStatus(s, false) == asynctask.StatusFailed {
out["status"] = s
continue
}
}
if _, exists := out[k]; !exists {
out[k] = v
}
}
return out
}
func preserveAitableWrapperFields(dst, layer map[string]any) {
for k, v := range layer {
switch k {
case "content", "data", "result":
continue
}
if _, ok := v.(map[string]any); ok {
continue
}
if _, exists := dst[k]; !exists {
dst[k] = v
}
}
if s := firstAitableString(layer, "status", "state"); normalizeAsyncStatus(s, false) == asynctask.StatusFailed {
dst["status"] = s
}
}
func normalizeAitableDownloadURL(raw string) string {
url := strings.TrimSpace(raw)
if url == "" || strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return url
}
return "https://" + url
}
func firstAitableString(values map[string]any, keys ...string) string {
for _, key := range keys {
if s, ok := values[key].(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func copyAitableMap(values map[string]any) map[string]any {
out := make(map[string]any, len(values))
for k, v := range values {
out[k] = v
}
return out
}
// parseAitableExportQueryResult 解析 export_data 查询返回。
func parseAitableExportQueryResult(resp map[string]any) asynctask.QueryResult {
data := unwrapAitableResp(resp)
statusRaw := firstAitableString(data, "status", "state")
url := normalizeAitableDownloadURL(firstAitableString(data, "downloadUrl", "downloadURL", "url"))
msg := firstAitableString(data, "message", "msg", "errorMessage")
// 兼容:部分上游用 SUCCEED / FAILURE 等变体
st := normalizeAsyncStatus(statusRaw, url != "")
if st == asynctask.StatusSuccess && url == "" {
st = asynctask.StatusProcessing
}
return asynctask.QueryResult{
Status: st,
DownloadURL: url,
Message: msg,
Raw: data,
}
}
// normalizeAsyncStatus 把各种 status 变体规范化到 asynctask.Status。
// hasResult=true 时即便 status 缺失也判定 SUCCESS(部分上游用"data 已就位"暗示完成)。
func normalizeAsyncStatus(raw string, hasResult bool) asynctask.Status {
s := strings.ToUpper(strings.TrimSpace(raw))
switch s {
case "SUCCESS", "SUCCEED", "SUCCEEDED", "DONE", "FINISHED", "COMPLETE", "COMPLETED":
return asynctask.StatusSuccess
case "FAILED", "FAILURE", "ERROR":
return asynctask.StatusFailed
case "PROCESSING", "RUNNING", "PENDING", "QUEUED", "IN_PROGRESS":
return asynctask.StatusProcessing
case "":
if hasResult {
return asynctask.StatusSuccess
}
return asynctask.StatusProcessing
default:
// 未知状态:保守处理为 processing 让上层继续等
return asynctask.StatusProcessing
}
}