附件上传dws合并为一个 to #666

This commit is contained in:
昭逸 committed 2026-08-20 15:14:39 +08:00
1 parent 103b05413e
commit cefc5c005c
9 files changed
+1303 -267

No files matched your search

+1 -2
View File
@@ -2,5 +2,4 @@
category: Added
---
- **OA approval attachment upload** — `dws oa approval attachment init-upload` initializes an approval attachment upload, returning the OSS upload credential (MCP `oa/init_attachment_upload_info`).
- **OA approval attachment commit** — `dws oa approval attachment commit-upload` commits an uploaded approval attachment to finish the upload flow (MCP `oa/commit_attachment_upload_info`).
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
+857
View File
@@ -0,0 +1,857 @@
diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/oa-approval-attachment-upload.md
index f2bbe2fd..6d4089c7 100644
--- a/.changes/oa-approval-attachment-upload.md
+++ b/.changes/oa-approval-attachment-upload.md
@@ -2,5 +2,4 @@
category: Added
---
-- **OA approval attachment upload** — `dws oa approval attachment init-upload` initializes an approval attachment upload, returning the OSS upload credential (MCP `oa/init_attachment_upload_info`).
-- **OA approval attachment commit** — `dws oa approval attachment commit-upload` commits an uploaded approval attachment to finish the upload flow (MCP `oa/commit_attachment_upload_info`).
+- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go
index e175c275..33a0f359 100644
--- a/internal/app/schema_oa_attachment_contract_test.go
+++ b/internal/app/schema_oa_attachment_contract_test.go
@@ -54,30 +54,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
"spaceId": "integer", "agentId": "integer", "class": "string",
},
},
- {
- cliPath: "oa approval attachment init-upload",
- canonical: "oa.init_attachment_upload_info",
- rpc: "init_attachment_upload_info",
- description: "初始化审批附件上传信息,返回 OSS 上传凭证",
- effect: "read",
- resultType: "object",
- resultFields: map[string]string{
- "uploadKey": "string", "resourceUrls": "array", "storageDriver": "string",
- "expirationSeconds": "integer", "region": "string", "class": "string",
- },
- },
- {
- cliPath: "oa approval attachment commit-upload",
- canonical: "oa.commit_attachment_upload_info",
- rpc: "commit_attachment_upload_info",
- description: "提交附件上传信息,完成附件上传流程",
- effect: "write",
- resultType: "object",
- resultFields: map[string]string{
- "spaceId": "integer", "fileName": "string", "fileSize": "integer",
- "class": "string", "fileType": "string", "fileId": "string",
- },
- },
}
for _, test := range tests {
@@ -160,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
+// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
+// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
+// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
+func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
+ snapshot := fullSchemaSnapshotForTest(t)
+ tool := snapshot.Tools["oa.attachment_upload"]
+ if tool == nil {
+ t.Fatal("oa.attachment_upload is missing from final Schema")
+ }
+ if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
+ t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
+ }
+ if got := schemaContractString(tool["interface_mode"]); got != "composite" {
+ t.Fatalf("interface_mode = %q, want composite", got)
+ }
+ if got := schemaContractString(tool["availability"]); got != "available" {
+ t.Fatalf("availability = %q, want available", got)
+ }
+ if got := schemaContractString(tool["interface_reason"]); got == "" {
+ t.Fatal("composite upload command must document an interface reason")
+ }
+ if got := schemaContractString(tool["effect"]); got != "write" {
+ t.Fatalf("effect = %q, want write", got)
+ }
+ if got := schemaContractString(tool["risk"]); got != "low" {
+ t.Fatalf("risk = %q, want low", got)
+ }
+ if got := schemaContractString(tool["confirmation"]); got != "not_required" {
+ t.Fatalf("confirmation = %q, want not_required", got)
+ }
+ parameters := schemaContractMap(tool["parameters"])
+ for _, flag := range []string{"file", "file-name", "md5"} {
+ if parameters[flag] == nil {
+ t.Fatalf("upload --%s is missing from final Schema", flag)
+ }
+ }
+ if required, _ := parameters["file"]["required"].(bool); !required {
+ t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
+ }
+ result := schemaContractMap(tool["result"])
+ dataSchema := schemaContractMap(result["data_schema"])
+ properties := schemaContractMap(dataSchema["properties"])
+ if properties["fileId"] == nil {
+ t.Fatal("upload Result data_schema is missing fileId")
+ }
+}
+
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go
index 4326b493..d1404700 100644
--- a/internal/helpers/oa.go
+++ b/internal/helpers/oa.go
@@ -2,12 +2,16 @@ package helpers
import (
"bytes"
+ "context"
"encoding/json"
"errors"
"fmt"
"io"
+ "os"
+ "path/filepath"
"strconv"
"strings"
+ "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -125,7 +129,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
- data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
+ return callOAAttachmentResultCtx(cmd.Context(), tool, args)
+}
+
+// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
+// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
+func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
+ data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
if err != nil {
return nil, err
}
@@ -140,13 +150,106 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
return output.Success(result), nil
}
-// validateOAAttachmentFileSize 校验 --file-size 非负,与 MCP inputSchema 的
-// fileSize >= 0 约束保持一致,避免把非法负值下发到后端。
-func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error {
- if size, _ := cmd.Flags().GetInt("file-size"); size < 0 {
- return apperrors.NewValidation("--file-size 必须大于等于 0")
+// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
+// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
+// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
+func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
+ var data map[string]any
+ if err = json.Unmarshal([]byte(text), &data); err != nil {
+ return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
}
- return nil
+ result, ok := data["result"].(map[string]any)
+ if !ok {
+ return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
+ }
+
+ uploadKey, _ = result["uploadKey"].(string)
+ if strings.TrimSpace(uploadKey) == "" {
+ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
+ }
+
+ rawURLs, ok := result["resourceUrls"].([]any)
+ if !ok || len(rawURLs) == 0 {
+ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
+ }
+ resourceURL, _ = rawURLs[0].(string)
+ if strings.TrimSpace(resourceURL) == "" {
+ return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
+ }
+
+ headers = make(map[string]string)
+ if rawHeaders, ok := result["headers"].(map[string]any); ok {
+ for key, value := range rawHeaders {
+ if str, ok := value.(string); ok {
+ headers[key] = str
+ }
+ }
+ }
+ return resourceURL, headers, uploadKey, nil
+}
+
+// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
+// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
+// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
+func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
+ filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
+ if filePath == "" {
+ return apperrors.NewValidation("--file 不能为空")
+ }
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
+ }
+ if info.IsDir() {
+ return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
+ }
+ fileSize := info.Size()
+
+ fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
+ if fileName == "" {
+ fileName = filepath.Base(filePath)
+ }
+
+ md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
+ if md5Hex == "" {
+ md5Hex, err = fileMD5Hex(filePath)
+ if err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
+ defer cancel()
+
+ // Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
+ initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
+ "fileName": fileName,
+ "fileSize": float64(fileSize),
+ "md5": md5Hex,
+ })
+ if err != nil {
+ return err
+ }
+ resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
+ if err != nil {
+ return err
+ }
+
+ // Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
+ if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
+ return err
+ }
+
+ // Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。
+ result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{
+ "fileName": fileName,
+ "uploadKey": uploadKey,
+ "fileSize": float64(fileSize),
+ })
+ if err != nil {
+ return err
+ }
+ return output.StoreResult(cmd.Context(), result)
}
func newOAAttachmentCommand() *cobra.Command {
@@ -327,113 +430,64 @@ func newOAAttachmentCommand() *cobra.Command {
},
})
- initUploadCmd := NewLeafCommand(LeafSpec{
- Use: "init-upload",
- Short: "初始化审批附件上传信息,使用获取到的信息上传文件",
- Example: " dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400",
- Server: "oa",
- Tool: "init_attachment_upload_info",
- OutputRollout: output.RolloutUnifiedActive,
- ResultCall: callOAAttachmentResult,
- Flags: []LeafFlag{
- {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true},
- {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true},
- {Name: "md5", Usage: "文件原始字节内容的 MD5,32位十六进制字符串", Bind: "md5", Trim: true, OmitEmpty: true},
- },
- Validate: validateOAAttachmentFileSize,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: LeafContract{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "init_attachment_upload_info",
- CanonicalPath: "oa.init_attachment_upload_info",
- CLIPath: "oa approval attachment init-upload",
- PrimaryCLIPath: "oa approval attachment init-upload",
- },
- Description: "初始化审批附件上传信息,返回 OSS 上传凭证",
- Result: &contract.ResultSpec{
- Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
- DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传初始化信息","properties":{"uploadKey":{"type":"string","description":"上传凭证标识"},"resourceUrls":{"type":"array","description":"资源上传地址列表","items":{"type":"string","description":"单个上传地址"}},"storageDriver":{"type":"string","description":"存储驱动类型"},"expirationSeconds":{"type":"integer","description":"凭证过期秒数"},"region":{"type":"string","description":"存储区域"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["uploadKey","resourceUrls"],"additionalProperties":true}`),
- },
- Interface: &contract.InterfaceSpec{
- Mode: "mcp",
- Availability: "available",
- Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "init_attachment_upload_info"},
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "初始化审批附件上传,获取 OSS 上传凭证和地址",
- UseWhen: []string{"需要上传审批附件,先初始化获取上传凭证时"},
- AvoidWhen: []string{
- "文件已上传完成需要提交确认时使用 attachment commit-upload",
- "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview",
- },
- Examples: []string{"dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400"},
- },
- Parameters: []contract.ParamDecl{
- {Name: "file-name", Property: "fileName", InterfaceType: "string"},
- {Name: "file-size", Property: "fileSize", InterfaceType: "integer"},
- {Name: "md5", Property: "md5", InterfaceType: "string"},
- },
- },
- })
+ uploadCmd := &cobra.Command{
+ Use: "upload",
+ Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
+ Long: `上传本地文件为审批附件(三步自动完成)。
- commitUploadCmd := NewLeafCommand(LeafSpec{
- Use: "commit-upload",
- Short: "提交附件上传信息",
- Example: " dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400",
- Server: "oa",
- Tool: "commit_attachment_upload_info",
+流程:
+ 1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
+ 2. HTTP PUT 上传文件二进制到 OSS
+ 3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
+
+--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
+ Example: ` dws oa approval attachment upload --file ./合同.pdf
+ dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
+ dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
+ RunE: runOAAttachmentUpload,
+ }
+ DeclareLeafMetadata(uploadCmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
- ResultCall: callOAAttachmentResult,
- Flags: []LeafFlag{
- {Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true},
- {Name: "upload-key", Usage: "初始化接口返回的 uploadKey (必填)", Bind: "uploadKey", Trim: true, Required: true, MarkRequired: true},
- {Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true},
- },
- Validate: validateOAAttachmentFileSize,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
+ Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
- Name: "commit_attachment_upload_info",
- CanonicalPath: "oa.commit_attachment_upload_info",
- CLIPath: "oa approval attachment commit-upload",
- PrimaryCLIPath: "oa approval attachment commit-upload",
+ Name: "attachment_upload",
+ CanonicalPath: "oa.attachment_upload",
+ CLIPath: "oa approval attachment upload",
+ PrimaryCLIPath: "oa approval attachment upload",
},
- Description: "提交附件上传信息,完成附件上传流程",
+ Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
+ DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
- Mode: "mcp",
+ Mode: "composite",
Availability: "available",
- Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "commit_attachment_upload_info"},
+ Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
- AgentSummary: "提交附件上传信息,确认文件上传完成",
- UseWhen: []string{"文件已通过 OSS 上传完成,需要提交确认附件上传时"},
+ AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
+ UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
AvoidWhen: []string{
- "尚未初始化上传信息时使用 attachment init-upload",
- "需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview",
+ "仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
+ "仅需获取已有附件临时下载链接时使用 attachment download-url",
},
- Examples: []string{"dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400"},
- },
- Parameters: []contract.ParamDecl{
- {Name: "file-name", Property: "fileName", InterfaceType: "string"},
- {Name: "upload-key", Property: "uploadKey", InterfaceType: "string"},
- {Name: "file-size", Property: "fileSize", InterfaceType: "integer"},
+ Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
},
},
})
+ uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
+ uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
+ uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
+ _ = uploadCmd.MarkFlagRequired("file")
- attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, initUploadCmd, commitUploadCmd)
+ attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
return attachmentCmd
}
diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go
index 8eb9e1f3..7b66ad58 100644
--- a/internal/helpers/oa_attachment_test.go
+++ b/internal/helpers/oa_attachment_test.go
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
+ "path/filepath"
"reflect"
+ "strconv"
"strings"
"testing"
@@ -338,123 +340,174 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
}
}
-func TestCrossPlatformCoverageOAAttachmentInitUploadPayload(t *testing.T) {
- const response = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"storageDriver":"oss","expirationSeconds":3600,"region":"cn-hangzhou","class":"com.dingtalk.oapi.response"},"success":true}`
- commandArgs := []string{
- "approval", "attachment", "init-upload",
+const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
+
+func oaUploadCommitResponse(size int64) string {
+ return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
+ strconv.FormatInt(size, 10) +
+ `,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
+}
+
+// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
+func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), name)
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("write temp file: %v", err)
+ }
+ return path, int64(len(content))
+}
+
+// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
+// resourceURL 与签名 headers。
+type capturedPut struct {
+ calls int
+ url string
+ headers map[string]string
+ filePath string
+ fileSize int64
+}
+
+func mockOAAttachmentPut(t *testing.T) *capturedPut {
+ t.Helper()
+ put := &capturedPut{}
+ SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
+ put.calls++
+ put.url = url
+ put.headers = headers
+ put.filePath = filePath
+ put.fileSize = fileSize
+ return nil
+ })
+ t.Cleanup(func() { SetHTTPPutFile(nil) })
+ return put
+}
+
+func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
+ const content = "pdf-bytes-content"
+ filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
+ put := mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {text: oaUploadCommitResponse(fileSize)},
+ }}
+ stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
"--file-name", "合同.pdf",
- "--file-size", "102400",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err != nil {
+ t.Fatalf("execute command: %v", err)
}
- wantArgs := map[string]any{
+
+ if caller.calls != 2 {
+ t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
+ }
+ if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
+ t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
+ }
+ wantInit := map[string]any{
"fileName": "合同.pdf",
- "fileSize": 102400,
+ "fileSize": float64(fileSize),
"md5": "d41d8cd98f00b204e9800998ecf8427e",
}
+ if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
+ t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
+ }
- t.Run("json", func(t *testing.T) {
- caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
- stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
- if err != nil {
- t.Fatalf("execute command: %v", err)
- }
- if caller.server != "oa" || caller.tool != "init_attachment_upload_info" {
- t.Fatalf("called %s/%s, want oa/init_attachment_upload_info", caller.server, caller.tool)
- }
- if !reflect.DeepEqual(caller.args, wantArgs) {
- t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
- }
- var envelope map[string]any
- if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
- t.Fatalf("decode unified output: %v", err)
- }
- if envelope["ok"] != true || envelope["outcome"] != "success" {
- t.Fatalf("unified envelope = %#v", envelope)
- }
- data, ok := envelope["data"].(map[string]any)
- if !ok || data["uploadKey"] != "key-123" {
- t.Fatalf("unified data = %#v", envelope["data"])
- }
- })
-
- t.Run("optional md5 omitted", func(t *testing.T) {
- caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}}
- if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
- "approval", "attachment", "init-upload",
- "--file-name", "report.xlsx",
- "--file-size", "2048",
- ); err != nil {
- t.Fatalf("execute command: %v", err)
- }
- if _, exists := caller.args["md5"]; exists {
- t.Fatalf("optional md5 should be omitted: %#v", caller.args)
- }
- })
-}
+ if put.calls != 1 {
+ t.Fatalf("httpPutFile called %d times, want 1", put.calls)
+ }
+ if put.url != "https://oss.example.test/upload" {
+ t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
+ }
+ if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
+ t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
+ }
+ if put.filePath != filePath || put.fileSize != fileSize {
+ t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
+ }
-func TestCrossPlatformCoverageOAAttachmentCommitUploadPayload(t *testing.T) {
- const response = `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":102400,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
- commandArgs := []string{
- "approval", "attachment", "commit-upload",
- "--file-name", "合同.pdf",
- "--upload-key", "key-123",
- "--file-size", "102400",
+ if caller.toolLog[1] != "commit_attachment_upload_info" {
+ t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
}
- wantArgs := map[string]any{
+ wantCommit := map[string]any{
"fileName": "合同.pdf",
"uploadKey": "key-123",
- "fileSize": 102400,
+ "fileSize": float64(fileSize),
+ }
+ if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
+ t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
}
- t.Run("json", func(t *testing.T) {
- caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
- stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
- if err != nil {
- t.Fatalf("execute command: %v", err)
- }
- if caller.server != "oa" || caller.tool != "commit_attachment_upload_info" {
- t.Fatalf("called %s/%s, want oa/commit_attachment_upload_info", caller.server, caller.tool)
- }
- if !reflect.DeepEqual(caller.args, wantArgs) {
- t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
- }
- var envelope map[string]any
- if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
- t.Fatalf("decode unified output: %v", err)
- }
- if envelope["ok"] != true || envelope["outcome"] != "success" {
- t.Fatalf("unified envelope = %#v", envelope)
- }
- data, ok := envelope["data"].(map[string]any)
- if !ok || data["fileId"] != "file-abc" {
- t.Fatalf("unified data = %#v", envelope["data"])
- }
- })
+ var envelope map[string]any
+ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
+ t.Fatalf("decode unified output: %v", err)
+ }
+ if envelope["ok"] != true || envelope["outcome"] != "success" {
+ t.Fatalf("unified envelope = %#v", envelope)
+ }
+ data, ok := envelope["data"].(map[string]any)
+ if !ok || data["fileId"] != "file-abc" {
+ t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
+ }
+}
+
+func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
+ const content = "auto-md5-content"
+ filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
+ wantMD5, err := fileMD5Hex(filePath)
+ if err != nil {
+ t.Fatalf("compute expected md5: %v", err)
+ }
+ mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {text: oaUploadCommitResponse(fileSize)},
+ }}
+ // 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
+ if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ ); err != nil {
+ t.Fatalf("execute command: %v", err)
+ }
+
+ if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
+ t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
+ }
+ if got := caller.argsLog[0]["md5"]; got != wantMD5 {
+ t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
+ }
+ if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
+ t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
+ }
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
+ existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
tests := []struct {
name string
args []string
}{
- {name: "init-upload missing file-name", args: []string{"approval", "attachment", "init-upload", "--file-size", "1024"}},
- {name: "init-upload missing file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf"}},
- {name: "commit-upload missing file-name", args: []string{"approval", "attachment", "commit-upload", "--upload-key", "k", "--file-size", "1024"}},
- {name: "commit-upload missing upload-key", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--file-size", "1024"}},
- {name: "commit-upload missing file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k"}},
- {name: "init-upload negative file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf", "--file-size", "-1"}},
- {name: "commit-upload negative file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k", "--file-size", "-1"}},
+ {name: "missing file", args: []string{"approval", "attachment", "upload"}},
+ {name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
+ {name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
+ mockOAAttachmentPut(t)
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller, test.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded", test.name)
}
if caller.calls != 0 {
- t.Fatalf("missing required flag made %d MCP call(s)", caller.calls)
+ t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
}
})
}
diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go
index 8b58bdbf..5cc02f26 100644
--- a/internal/helpers/polling_coverage_more_test.go
+++ b/internal/helpers/polling_coverage_more_test.go
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
server string
tool string
args map[string]any
+ // Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
+ // can assert each invocation instead of only the last one captured above.
+ serverLog []string
+ toolLog []string
+ argsLog []map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
c.server = serverID
c.tool = toolName
c.args = args
+ c.serverLog = append(c.serverLog, serverID)
+ c.toolLog = append(c.toolLog, toolName)
+ c.argsLog = append(c.argsLog, args)
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md
index a0cb242b..6add42e9 100644
--- a/skills/mono/references/products/oa.md
+++ b/skills/mono/references/products/oa.md
@@ -34,8 +34,7 @@ Flags:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
-- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload`
-- 文件已上传至 OSS,需要提交确认:`attachment commit-upload`
+- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -80,35 +79,20 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
-#### 初始化审批附件上传
+#### 上传本地文件为审批附件
```
Usage:
- dws oa approval attachment init-upload [flags]
+ dws oa approval attachment upload [flags]
Example:
- dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json
+ dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
- --file-name string 完整文件名,例如 合同.pdf (必填)
- --file-size int 文件字节数,必须大于等于 0 (必填)
- --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选)
+ --file string 本地文件路径 (必填)
+ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
+ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
-该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。
-
-#### 提交附件上传信息
-
-```
-Usage:
- dws oa approval attachment commit-upload [flags]
-Example:
- dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400 --format json
-Flags:
- --file-name string 完整文件名,例如 合同.pdf (必填)
- --upload-key string 初始化接口返回的 uploadKey (必填)
- --file-size int 文件字节数,必须大于等于 0 (必填)
-```
-
-该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。
+该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
@@ -753,8 +737,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
-用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5)
-用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size)
+用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md
index 959fb48f..e8800eeb 100644
--- a/skills/multi/dingtalk-misc/references/oa.md
+++ b/skills/multi/dingtalk-misc/references/oa.md
@@ -38,8 +38,7 @@ Flags:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
-- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload`
-- 文件已上传至 OSS,需要提交确认:`attachment commit-upload`
+- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -84,35 +83,20 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
-#### 初始化审批附件上传
+#### 上传本地文件为审批附件
```
Usage:
- dws oa approval attachment init-upload [flags]
+ dws oa approval attachment upload [flags]
Example:
- dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json
+ dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
- --file-name string 完整文件名,例如 合同.pdf (必填)
- --file-size int 文件字节数,必须大于等于 0 (必填)
- --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选)
+ --file string 本地文件路径 (必填)
+ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
+ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
-该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。
-
-#### 提交附件上传信息
-
-```
-Usage:
- dws oa approval attachment commit-upload [flags]
-Example:
- dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400 --format json
-Flags:
- --file-name string 完整文件名,例如 合同.pdf (必填)
- --upload-key string 初始化接口返回的 uploadKey (必填)
- --file-size int 文件字节数,必须大于等于 0 (必填)
-```
-
-该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。
+该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
@@ -756,8 +740,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
-用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5)
-用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size)
+用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go
index 25dd8b64..ba74452e 100644
--- a/test/unit/oa_attachment_delivery_policy_test.go
+++ b/test/unit/oa_attachment_delivery_policy_test.go
@@ -37,8 +37,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) {
"download-url": "get_attachment_download_url",
"authorize-download": "auth_download_file",
"authorize-preview": "auth_preview_attachment",
- "init-upload": "init_attachment_upload_info",
- "commit-upload": "commit_attachment_upload_info",
}
gotTools := make(map[string]string, len(wantTools))
foundConstructor := false
@@ -80,8 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) {
"dws oa approval attachment download-url",
"dws oa approval attachment authorize-download",
"dws oa approval attachment authorize-preview",
- "dws oa approval attachment init-upload",
- "dws oa approval attachment commit-upload",
+ "dws oa approval attachment upload",
"临时下载链接",
"最多 10",
"最多 20",
@@ -54,30 +54,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
"spaceId": "integer", "agentId": "integer", "class": "string",
},
},
{
cliPath: "oa approval attachment init-upload",
canonical: "oa.init_attachment_upload_info",
rpc: "init_attachment_upload_info",
description: "初始化审批附件上传信息,返回 OSS 上传凭证",
effect: "read",
resultType: "object",
resultFields: map[string]string{
"uploadKey": "string", "resourceUrls": "array", "storageDriver": "string",
"expirationSeconds": "integer", "region": "string", "class": "string",
},
},
{
cliPath: "oa approval attachment commit-upload",
canonical: "oa.commit_attachment_upload_info",
rpc: "commit_attachment_upload_info",
description: "提交附件上传信息,完成附件上传流程",
effect: "write",
resultType: "object",
resultFields: map[string]string{
"spaceId": "integer", "fileName": "string", "fileSize": "integer",
"class": "string", "fileType": "string", "fileId": "string",
},
},
}
for _, test := range tests {
@@ -160,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["oa.attachment_upload"]
if tool == nil {
t.Fatal("oa.attachment_upload is missing from final Schema")
}
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("interface_mode = %q, want composite", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Fatal("composite upload command must document an interface reason")
}
if got := schemaContractString(tool["effect"]); got != "write" {
t.Fatalf("effect = %q, want write", got)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
parameters := schemaContractMap(tool["parameters"])
for _, flag := range []string{"file", "file-name", "md5"} {
if parameters[flag] == nil {
t.Fatalf("upload --%s is missing from final Schema", flag)
}
}
if required, _ := parameters["file"]["required"].(bool); !required {
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
if properties["fileId"] == nil {
t.Fatal("upload Result data_schema is missing fileId")
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
+150 -91
View File
@@ -2,12 +2,16 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -125,7 +129,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
return callOAAttachmentResultCtx(cmd.Context(), tool, args)
}
// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
if err != nil {
return nil, err
}
@@ -140,13 +150,111 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
return output.Success(result), nil
}
// validateOAAttachmentFileSize 校验 --file-size 非负,与 MCP inputSchema 的
// fileSize >= 0 约束保持一致,避免把非法负值下发到后端。
func validateOAAttachmentFileSize(cmd *cobra.Command, _ []string) error {
if size, _ := cmd.Flags().GetInt("file-size"); size < 0 {
return apperrors.NewValidation("--file-size 必须大于等于 0")
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
var data map[string]any
if err = json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
}
return nil
result, ok := data["result"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
}
uploadKey, _ = result["uploadKey"].(string)
if strings.TrimSpace(uploadKey) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
}
rawURLs, ok := result["resourceUrls"].([]any)
if !ok || len(rawURLs) == 0 {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
}
resourceURL, _ = rawURLs[0].(string)
if strings.TrimSpace(resourceURL) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
}
rawHeaders, ok := result["headers"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers")
}
headers = make(map[string]string)
for key, value := range rawHeaders {
if str, ok := value.(string); ok {
headers[key] = str
}
}
if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date")
}
return resourceURL, headers, uploadKey, nil
}
// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
if filePath == "" {
return apperrors.NewValidation("--file 不能为空")
}
info, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
}
if info.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
}
fileSize := info.Size()
fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
if fileName == "" {
fileName = filepath.Base(filePath)
}
md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
if md5Hex == "" {
md5Hex, err = fileMD5Hex(filePath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
}
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"md5": md5Hex,
})
if err != nil {
return err
}
resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
if err != nil {
return err
}
// Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。
result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{
"fileName": fileName,
"uploadKey": uploadKey,
"fileSize": float64(fileSize),
})
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), result)
}
func newOAAttachmentCommand() *cobra.Command {
@@ -327,113 +435,64 @@ func newOAAttachmentCommand() *cobra.Command {
},
})
initUploadCmd := NewLeafCommand(LeafSpec{
Use: "init-upload",
Short: "初始化审批附件上传信息,使用获取到的信息上传文件",
Example: " dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400",
Server: "oa",
Tool: "init_attachment_upload_info",
OutputRollout: output.RolloutUnifiedActive,
ResultCall: callOAAttachmentResult,
Flags: []LeafFlag{
{Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true},
{Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true},
{Name: "md5", Usage: "文件原始字节内容的 MD5,32位十六进制字符串", Bind: "md5", Trim: true, OmitEmpty: true},
},
Validate: validateOAAttachmentFileSize,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "init_attachment_upload_info",
CanonicalPath: "oa.init_attachment_upload_info",
CLIPath: "oa approval attachment init-upload",
PrimaryCLIPath: "oa approval attachment init-upload",
},
Description: "初始化审批附件上传信息,返回 OSS 上传凭证",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传初始化信息","properties":{"uploadKey":{"type":"string","description":"上传凭证标识"},"resourceUrls":{"type":"array","description":"资源上传地址列表","items":{"type":"string","description":"单个上传地址"}},"storageDriver":{"type":"string","description":"存储驱动类型"},"expirationSeconds":{"type":"integer","description":"凭证过期秒数"},"region":{"type":"string","description":"存储区域"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["uploadKey","resourceUrls"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "init_attachment_upload_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "初始化审批附件上传,获取 OSS 上传凭证和地址",
UseWhen: []string{"需要上传审批附件,先初始化获取上传凭证时"},
AvoidWhen: []string{
"文件已上传完成需要提交确认时使用 attachment commit-upload",
"需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview",
},
Examples: []string{"dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400"},
},
Parameters: []contract.ParamDecl{
{Name: "file-name", Property: "fileName", InterfaceType: "string"},
{Name: "file-size", Property: "fileSize", InterfaceType: "integer"},
{Name: "md5", Property: "md5", InterfaceType: "string"},
},
},
})
uploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
Long: `上传本地文件为审批附件(三步自动完成)。
commitUploadCmd := NewLeafCommand(LeafSpec{
Use: "commit-upload",
Short: "提交附件上传信息",
Example: " dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400",
Server: "oa",
Tool: "commit_attachment_upload_info",
流程:
1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
2. HTTP PUT 上传文件二进制到 OSS
3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
Example: ` dws oa approval attachment upload --file ./合同.pdf
dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
RunE: runOAAttachmentUpload,
}
DeclareLeafMetadata(uploadCmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
ResultCall: callOAAttachmentResult,
Flags: []LeafFlag{
{Name: "file-name", Usage: "完整文件名,例如 合同.pdf (必填)", Bind: "fileName", Trim: true, Required: true, MarkRequired: true},
{Name: "upload-key", Usage: "初始化接口返回的 uploadKey (必填)", Bind: "uploadKey", Trim: true, Required: true, MarkRequired: true},
{Name: "file-size", Usage: "文件字节数,必须大于等于 0 (必填)", Kind: LeafInt, Bind: "fileSize", Required: true, MarkRequired: true},
},
Validate: validateOAAttachmentFileSize,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "commit_attachment_upload_info",
CanonicalPath: "oa.commit_attachment_upload_info",
CLIPath: "oa approval attachment commit-upload",
PrimaryCLIPath: "oa approval attachment commit-upload",
Name: "attachment_upload",
CanonicalPath: "oa.attachment_upload",
CLIPath: "oa approval attachment upload",
PrimaryCLIPath: "oa approval attachment upload",
},
Description: "提交附件上传信息,完成附件上传流程",
Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Mode: "composite",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "commit_attachment_upload_info"},
Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "提交附件上传信息,确认文件上传完成",
UseWhen: []string{"文件已通过 OSS 上传完成,需要提交确认附件上传时"},
AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
AvoidWhen: []string{
"尚未初始化上传信息时使用 attachment init-upload",
"需要下载或预览已有附件时使用 attachment download-url 或 authorize-preview",
"仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
"仅需获取已有附件临时下载链接时使用 attachment download-url",
},
Examples: []string{"dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400"},
},
Parameters: []contract.ParamDecl{
{Name: "file-name", Property: "fileName", InterfaceType: "string"},
{Name: "upload-key", Property: "uploadKey", InterfaceType: "string"},
{Name: "file-size", Property: "fileSize", InterfaceType: "integer"},
Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
},
},
})
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
_ = uploadCmd.MarkFlagRequired("file")
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, initUploadCmd, commitUploadCmd)
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
return attachmentCmd
}
+221 -94
View File
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
@@ -338,123 +340,248 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
}
}
func TestCrossPlatformCoverageOAAttachmentInitUploadPayload(t *testing.T) {
const response = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"storageDriver":"oss","expirationSeconds":3600,"region":"cn-hangzhou","class":"com.dingtalk.oapi.response"},"success":true}`
commandArgs := []string{
"approval", "attachment", "init-upload",
"--file-name", "合同.pdf",
"--file-size", "102400",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
func oaUploadCommitResponse(size int64) string {
return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
strconv.FormatInt(size, 10) +
`,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
}
// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
wantArgs := map[string]any{
return path, int64(len(content))
}
// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
// resourceURL 与签名 headers。
type capturedPut struct {
calls int
url string
headers map[string]string
filePath string
fileSize int64
}
func mockOAAttachmentPut(t *testing.T) *capturedPut {
t.Helper()
put := &capturedPut{}
SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
put.calls++
put.url = url
put.headers = headers
put.filePath = filePath
put.fileSize = fileSize
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
return put
}
func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
const content = "pdf-bytes-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
}
wantInit := map[string]any{
"fileName": "合同.pdf",
"fileSize": 102400,
"fileSize": float64(fileSize),
"md5": "d41d8cd98f00b204e9800998ecf8427e",
}
t.Run("json", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.server != "oa" || caller.tool != "init_attachment_upload_info" {
t.Fatalf("called %s/%s, want oa/init_attachment_upload_info", caller.server, caller.tool)
}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || data["uploadKey"] != "key-123" {
t.Fatalf("unified data = %#v", envelope["data"])
}
})
t.Run("optional md5 omitted", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}}
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "init-upload",
"--file-name", "report.xlsx",
"--file-size", "2048",
); err != nil {
t.Fatalf("execute command: %v", err)
}
if _, exists := caller.args["md5"]; exists {
t.Fatalf("optional md5 should be omitted: %#v", caller.args)
}
})
}
func TestCrossPlatformCoverageOAAttachmentCommitUploadPayload(t *testing.T) {
const response = `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":102400,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
commandArgs := []string{
"approval", "attachment", "commit-upload",
"--file-name", "合同.pdf",
"--upload-key", "key-123",
"--file-size", "102400",
if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
}
wantArgs := map[string]any{
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
if put.url != "https://oss.example.test/upload" {
t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
}
if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
}
if put.filePath != filePath || put.fileSize != fileSize {
t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
}
if caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
}
wantCommit := map[string]any{
"fileName": "合同.pdf",
"uploadKey": "key-123",
"fileSize": 102400,
"fileSize": float64(fileSize),
}
if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
}
t.Run("json", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.server != "oa" || caller.tool != "commit_attachment_upload_info" {
t.Fatalf("called %s/%s, want oa/commit_attachment_upload_info", caller.server, caller.tool)
}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || data["fileId"] != "file-abc" {
t.Fatalf("unified data = %#v", envelope["data"])
}
})
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || data["fileId"] != "file-abc" {
t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
}
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
const content = "auto-md5-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
// 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
); err != nil {
t.Fatalf("execute command: %v", err)
}
if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
}
if got := caller.argsLog[0]["md5"]; got != wantMD5 {
t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
}
if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
}
}
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
tests := []struct {
name string
args []string
name string
response string
wantErr string
}{
{name: "init-upload missing file-name", args: []string{"approval", "attachment", "init-upload", "--file-size", "1024"}},
{name: "init-upload missing file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf"}},
{name: "commit-upload missing file-name", args: []string{"approval", "attachment", "commit-upload", "--upload-key", "k", "--file-size", "1024"}},
{name: "commit-upload missing upload-key", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--file-size", "1024"}},
{name: "commit-upload missing file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k"}},
{name: "init-upload negative file-size", args: []string{"approval", "attachment", "init-upload", "--file-name", "test.pdf", "--file-size", "-1"}},
{name: "commit-upload negative file-size", args: []string{"approval", "attachment", "commit-upload", "--file-name", "test.pdf", "--upload-key", "k", "--file-size", "-1"}},
{
name: "missing result",
response: `{"success":true}`,
wantErr: "缺少 result",
},
{
name: "missing uploadKey",
response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty uploadKey",
response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty resourceUrls",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 resourceUrls",
},
{
name: "missing headers",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
wantErr: "缺少 headers",
},
{
name: "headers missing Authorization",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
wantErr: "Authorization",
},
{
name: "headers missing x-oss-date",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
wantErr: "x-oss-date",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: test.response},
// second step should never be reached
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "test.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
tests := []struct {
name string
args []string
}{
{name: "missing file", args: []string{"approval", "attachment", "upload"}},
{name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
{name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller, test.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded", test.name)
}
if caller.calls != 0 {
t.Fatalf("missing required flag made %d MCP call(s)", caller.calls)
t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
}
})
}
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
server string
tool string
args map[string]any
// Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
// can assert each invocation instead of only the last one captured above.
serverLog []string
toolLog []string
argsLog []map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
c.server = serverID
c.tool = toolName
c.args = args
c.serverLog = append(c.serverLog, serverID)
c.toolLog = append(c.toolLog, toolName)
c.argsLog = append(c.argsLog, args)
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
+9 -26
View File
@@ -34,8 +34,7 @@ Flags:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload`
- 文件已上传至 OSS,需要提交确认:`attachment commit-upload`
- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -80,35 +79,20 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
#### 初始化审批附件上传
#### 上传本地文件为审批附件
```
Usage:
dws oa approval attachment init-upload [flags]
dws oa approval attachment upload [flags]
Example:
dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json
dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
--file-name string 完整文件名,例如 合同.pdf (必填)
--file-size int 文件字节数,必须大于等于 0 (必填)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选)
--file string 本地文件路径 (必填)
--file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。
#### 提交附件上传信息
```
Usage:
dws oa approval attachment commit-upload [flags]
Example:
dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400 --format json
Flags:
--file-name string 完整文件名,例如 合同.pdf (必填)
--upload-key string 初始化接口返回的 uploadKey (必填)
--file-size int 文件字节数,必须大于等于 0 (必填)
```
该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。
该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
@@ -753,8 +737,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5)
用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size)
用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
+9 -26
View File
@@ -38,8 +38,7 @@ Flags:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
- 需要上传审批附件,先初始化获取上传凭证:`attachment init-upload`
- 文件已上传至 OSS,需要提交确认:`attachment commit-upload`
- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -84,35 +83,20 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
#### 初始化审批附件上传
#### 上传本地文件为审批附件
```
Usage:
dws oa approval attachment init-upload [flags]
dws oa approval attachment upload [flags]
Example:
dws oa approval attachment init-upload --file-name 合同.pdf --file-size 102400 --format json
dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
--file-name string 完整文件名,例如 合同.pdf (必填)
--file-size int 文件字节数,必须大于等于 0 (必填)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选)
--file string 本地文件路径 (必填)
--file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
该命令初始化审批附件上传,返回 OSS 上传凭证和地址。上传流程:先调用 `attachment init-upload` 获取 uploadKey 和 resourceUrls,使用返回信息将文件上传到 OSS,最后调用 `attachment commit-upload` 提交确认。
#### 提交附件上传信息
```
Usage:
dws oa approval attachment commit-upload [flags]
Example:
dws oa approval attachment commit-upload --file-name 合同.pdf --upload-key <uploadKey> --file-size 102400 --format json
Flags:
--file-name string 完整文件名,例如 合同.pdf (必填)
--upload-key string 初始化接口返回的 uploadKey (必填)
--file-size int 文件字节数,必须大于等于 0 (必填)
```
该命令提交附件上传信息,完成附件上传流程。uploadKey 来自 `attachment init-upload` 的返回结果。
该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
@@ -756,8 +740,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
用户说"上传审批附件/初始化附件上传" → `approval attachment init-upload`(需 --file-name 和 --file-size;可选 --md5)
用户说"提交附件上传/确认附件上传" → `approval attachment commit-upload`(需 --file-name、--upload-key 和 --file-size)
用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
@@ -37,8 +37,6 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) {
"download-url": "get_attachment_download_url",
"authorize-download": "auth_download_file",
"authorize-preview": "auth_preview_attachment",
"init-upload": "init_attachment_upload_info",
"commit-upload": "commit_attachment_upload_info",
}
gotTools := make(map[string]string, len(wantTools))
foundConstructor := false
@@ -80,8 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) {
"dws oa approval attachment download-url",
"dws oa approval attachment authorize-download",
"dws oa approval attachment authorize-preview",
"dws oa approval attachment init-upload",
"dws oa approval attachment commit-upload",
"dws oa approval attachment upload",
"临时下载链接",
"最多 10",
"最多 20",