Files
dingtalk-workspace-cli/internal/helpers/aitable.go
T
2026-03-28 01:10:38 +08:00

497 lines
16 KiB
Go

// 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 (
"bufio"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return aitableHandler{}
})
}
type aitableHandler struct{}
func (aitableHandler) Name() string {
return "aitable"
}
func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "aitable",
Short: i18n.T("AITable 多维表格管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
base := &cobra.Command{
Use: "base",
Short: i18n.T("AI 表格 Base 管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
base.AddCommand(newAitableBaseDeleteCommand(runner))
table := &cobra.Command{
Use: "table",
Short: i18n.T("数据表管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
table.AddCommand(newAitableTableDeleteCommand(runner))
field := &cobra.Command{
Use: "field",
Short: i18n.T("字段管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
field.AddCommand(newAitableFieldDeleteCommand(runner))
record := &cobra.Command{
Use: "record",
Short: i18n.T("记录管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
record.AddCommand(newAitableRecordDeleteCommand(runner))
attachment := &cobra.Command{
Use: "attachment",
Short: i18n.T("附件工作流"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
attachment.AddCommand(newAITableUploadFileCommand(runner))
root.AddCommand(base, table, field, record, attachment)
return root
}
// ── base delete ────────────────────────────────────────────
func newAitableBaseDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: i18n.T("删除 AI 表格"),
Long: i18n.T("删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。"),
Example: " dws aitable base delete --base-id BASE_ID --yes",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, _ := cmd.Flags().GetString("base-id")
if strings.TrimSpace(baseID) == "" {
return apperrors.NewValidation("--base-id is required")
}
if !confirmDeletePrompt(cmd, i18n.T("AI 表格 Base"), baseID) {
return nil
}
params := map[string]any{"baseId": baseID}
if v, _ := cmd.Flags().GetString("reason"); v != "" {
params["reason"] = v
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_base", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_base", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
cmd.Flags().String("reason", "", i18n.T("删除原因(可选)"))
return cmd
}
// ── table delete ───────────────────────────────────────────
func newAitableTableDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: i18n.T("删除数据表"),
Long: i18n.T("删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。"),
Example: " dws aitable table delete --base-id BASE_ID --table-id TABLE_ID --yes",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, _ := cmd.Flags().GetString("base-id")
tableID, _ := cmd.Flags().GetString("table-id")
if strings.TrimSpace(baseID) == "" {
return apperrors.NewValidation("--base-id is required")
}
if strings.TrimSpace(tableID) == "" {
return apperrors.NewValidation("--table-id is required")
}
if !confirmDeletePrompt(cmd, i18n.T("数据表"), tableID) {
return nil
}
params := map[string]any{
"baseId": baseID,
"tableId": tableID,
}
if v, _ := cmd.Flags().GetString("reason"); v != "" {
params["reason"] = v
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_table", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_table", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
cmd.Flags().String("table-id", "", i18n.T("数据表 ID (必填)"))
cmd.Flags().String("reason", "", i18n.T("删除原因(可选)"))
return cmd
}
// ── field delete ───────────────────────────────────────────
func newAitableFieldDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: i18n.T("删除字段"),
Long: i18n.T("删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。"),
Example: " dws aitable field delete --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --yes",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, _ := cmd.Flags().GetString("base-id")
tableID, _ := cmd.Flags().GetString("table-id")
fieldID, _ := cmd.Flags().GetString("field-id")
if strings.TrimSpace(baseID) == "" {
return apperrors.NewValidation("--base-id is required")
}
if strings.TrimSpace(tableID) == "" {
return apperrors.NewValidation("--table-id is required")
}
if strings.TrimSpace(fieldID) == "" {
return apperrors.NewValidation("--field-id is required")
}
if !confirmDeletePrompt(cmd, i18n.T("字段"), fieldID) {
return nil
}
params := map[string]any{
"baseId": baseID,
"tableId": tableID,
"fieldId": fieldID,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_field", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_field", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
cmd.Flags().String("table-id", "", i18n.T("数据表 ID (必填)"))
cmd.Flags().String("field-id", "", i18n.T("字段 ID (必填)"))
return cmd
}
// ── record delete ──────────────────────────────────────────
func newAitableRecordDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: i18n.T("删除行记录"),
Long: i18n.T("批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。"),
Example: " dws aitable record delete --base-id BASE_ID --table-id TABLE_ID --record-ids rec1,rec2 --yes",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, _ := cmd.Flags().GetString("base-id")
tableID, _ := cmd.Flags().GetString("table-id")
recordIDsStr, _ := cmd.Flags().GetString("record-ids")
if strings.TrimSpace(baseID) == "" {
return apperrors.NewValidation("--base-id is required")
}
if strings.TrimSpace(tableID) == "" {
return apperrors.NewValidation("--table-id is required")
}
if strings.TrimSpace(recordIDsStr) == "" {
return apperrors.NewValidation("--record-ids is required")
}
if !confirmDeletePrompt(cmd, i18n.T("记录"), recordIDsStr) {
return nil
}
var recordIDs []any
for _, id := range strings.Split(recordIDsStr, ",") {
if s := strings.TrimSpace(id); s != "" {
recordIDs = append(recordIDs, s)
}
}
params := map[string]any{
"baseId": baseID,
"tableId": tableID,
"recordIds": recordIDs,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_records", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "delete_records", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
cmd.Flags().String("table-id", "", i18n.T("数据表 ID (必填)"))
cmd.Flags().String("record-ids", "", i18n.T("记录 ID 列表,逗号分隔 (必填)"))
return cmd
}
// confirmDeletePrompt asks for interactive confirmation before destructive operations.
// Returns true if --yes flag is set or user answers "yes"/"y".
func confirmDeletePrompt(cmd *cobra.Command, resourceType, resourceName string) bool {
yes, _ := cmd.Flags().GetBool("yes")
if yes {
return true
}
if commandDryRun(cmd) {
return true
}
fmt.Fprintf(cmd.ErrOrStderr(), i18n.T("⚠️ 即将删除 %s: %s\\n"), resourceType, resourceName)
fmt.Fprint(cmd.ErrOrStderr(), i18n.T("确认删除? (yes/no): "))
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
fmt.Fprintln(cmd.ErrOrStderr(), i18n.T("已取消操作"))
return false
}
// ── attachment upload-file ─────────────────────────────────
func newAITableUploadFileCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload-file",
Short: i18n.T("本地文件一键上传到 AITable 附件字段"),
Hidden: true,
Long: `完整流程 (自动执行 3 步):
1. dws aitable attachment upload → 获取 uploadUrl + fileToken
2. HTTP PUT 上传文件到 OSS
3. 返回 fileToken,可直接用于 record create/update`,
Example: " dws aitable attachment upload-file --base-id <BASE_ID> --file ./report.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
baseId, _ := cmd.Flags().GetString("base-id")
filePath, _ := cmd.Flags().GetString("file")
if baseId == "" {
return apperrors.NewValidation("--base-id is required")
}
if filePath == "" {
return apperrors.NewValidation("--file is required")
}
// Validate file
absPath, err := filepath.Abs(filePath)
if err != nil {
return apperrors.NewValidation(i18n.T("无法解析文件路径: ") + err.Error())
}
info, err := os.Stat(absPath)
if err != nil {
return apperrors.NewValidation(i18n.T("文件不存在: ") + absPath)
}
if info.IsDir() {
return apperrors.NewValidation(i18n.T("不是文件: ") + absPath)
}
fileSize := info.Size()
if fileSize <= 0 {
return apperrors.NewValidation(i18n.T("文件为空"))
}
maxFileSize := config.MaxUploadFileSize
if fileSize > maxFileSize {
return apperrors.NewValidation(fmt.Sprintf(i18n.T("文件过大 (%d 字节,限制 %d 字节)"), fileSize, maxFileSize))
}
fileName := filepath.Base(absPath)
mimeType := detectMIME(fileName)
// Step 1: prepare_attachment_upload
fmt.Fprintf(os.Stderr, i18n.T("步骤 1/3: 准备上传 %s (%d 字节, %s)...\\n"), fileName, fileSize, mimeType)
prepareParams := map[string]any{
"baseId": baseId,
"fileName": fileName,
"size": fileSize,
"mimeType": mimeType,
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "prepare_attachment_upload", prepareParams,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "aitable", "prepare_attachment_upload", prepareParams,
))
if err != nil {
return fmt.Errorf(i18n.T("准备上传失败: %w"), err)
}
resultMap := result.Response
if content, ok := resultMap["content"].(map[string]any); ok && len(content) > 0 {
resultMap = content
}
if resultMap == nil {
return apperrors.NewValidation(i18n.T("prepare_attachment_upload 返回格式异常"))
}
data, _ := resultMap["data"].(map[string]any)
if data == nil {
data = resultMap
}
uploadURL, _ := data["uploadUrl"].(string)
fileToken, _ := data["fileToken"].(string)
if uploadURL == "" || fileToken == "" {
return apperrors.NewValidation(i18n.T("返回数据缺少 uploadUrl 或 fileToken"))
}
// Step 2: HTTP PUT to OSS
fmt.Fprintln(os.Stderr, i18n.T("步骤 2/3: 上传文件到 OSS..."))
f, err := os.Open(absPath)
if err != nil {
return fmt.Errorf(i18n.T("无法打开文件: %w"), err)
}
defer f.Close()
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodPut, uploadURL, f)
if err != nil {
return fmt.Errorf(i18n.T("构建上传请求失败: %w"), err)
}
req.ContentLength = fileSize
req.Header.Set("Content-Type", mimeType)
uploadClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := uploadClient.Do(req)
if err != nil {
return fmt.Errorf(i18n.T("上传失败: %w"), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf(i18n.T("OSS 上传失败 HTTP %d: %s"), resp.StatusCode, string(body))
}
// Step 3: Return fileToken
fmt.Fprintln(os.Stderr, i18n.T("步骤 3/3: 上传完成!"))
output := map[string]any{
"fileToken": fileToken,
"fileName": fileName,
"size": fileSize,
"mimeType": mimeType,
}
return writeCommandPayload(cmd, output)
},
}
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
cmd.Flags().String("file", "", i18n.T("本地文件路径 (必填)"))
preferLegacyLeaf(cmd)
return cmd
}
func detectMIME(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
return "application/octet-stream"
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
return "application/octet-stream"
}
return mimeType
}