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 ` 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/.review_diff_oa_attachment.txt b/.review_diff_oa_attachment.txt new file mode 100644 index 00000000..42ef58a9 --- /dev/null +++ b/.review_diff_oa_attachment.txt @@ -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 ` 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/,提取 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 --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 --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 --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 --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", 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..b4c1cf00 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/,提取 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 --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 --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..5f58206f 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,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) } }) } 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 --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 --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",