From 6a2e9dd10e867977846bf1ef3e37febeda2b89f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Fri, 14 Aug 2026 17:41:02 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E7=9B=B8=E5=85=B3dws=EF=BC=8C=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=8E=88=E6=9D=83=E3=80=81=E4=B8=8B=E8=BD=BD=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E3=80=81=E8=8E=B7=E5=8F=96=E4=B8=8B=E8=BD=BD=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...a-attachment-download-url-output-design.md | 55 ++++ .../app/schema_oa_attachment_contract_test.go | 103 +++++++ internal/helpers/oa.go | 9 +- internal/helpers/oa_attachment.go | 268 ++++++++++++++++++ internal/helpers/oa_attachment_test.go | 239 ++++++++++++++++ 5 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md create mode 100644 internal/app/schema_oa_attachment_contract_test.go create mode 100644 internal/helpers/oa_attachment.go create mode 100644 internal/helpers/oa_attachment_test.go diff --git a/docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md b/docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md new file mode 100644 index 00000000..158452fd --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md @@ -0,0 +1,55 @@ +# OA Attachment Download URL Output Design + +## Goal + +Keep the existing command and MCP request unchanged while making the returned +OSS signed URL directly copyable from JSON output: + +```text +dws oa approval attachment download-url +``` + +## Scope + +Only `oa approval attachment download-url` changes. The other OA attachment +commands and the global JSON formatter retain their current behavior. + +## Design + +The command continues to invoke MCP server `oa`, tool +`get_attachment_download_url`, with the same arguments. Its leaf declaration +provides a command-specific `Call` callback that invokes the existing MCP +dispatcher with HTML escaping disabled when the selected output format is +JSON. This preserves literal `&` separators in `result.downloadUri` instead of +rendering them as `\u0026`. + +For `raw`, `table`, and other non-JSON formats, the callback uses the existing +escaped dispatcher behavior so their current rendering remains unchanged. + +The change does not alter the URL, decode or re-sign it, download the file, or +change global JSON serialization. + +## Error Handling + +Authentication, MCP transport, gateway, PAT, and business errors continue +through the existing dispatcher and retain their current classification and +output behavior. + +## Verification + +Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra +leaf in explicit JSON mode with a fake MCP result containing a signed URL. It +must verify: + +- the request still targets `oa/get_attachment_download_url`; +- the exact request arguments remain unchanged, including omission of the + optional boolean when the flag was not supplied; +- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`; +- stdout contains no `\u0026` escape. + +The fake caller must report JSON format (or the command must be executed with +`--format json`) so the test fails against the current escaped JSON path rather +than accidentally exercising raw MCP text output. + +Run the focused OA attachment tests, format modified Go files, and rebuild the +CLI. No commit is created. diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go new file mode 100644 index 00000000..82d48f04 --- /dev/null +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) { + tests := []struct { + cliPath string + canonical string + rpc string + description string + effect string + }{ + { + cliPath: "oa approval attachment download-url", + canonical: "oa.get_attachment_download_url", + rpc: "get_attachment_download_url", + description: "获取审批附件下载授权并生成临时下载链接", + effect: "read", + }, + { + cliPath: "oa approval attachment authorize-download", + canonical: "oa.auth_download_file", + rpc: "auth_download_file", + description: "批量授权当前用户下载指定的审批钉盘文件", + effect: "write", + }, + { + cliPath: "oa approval attachment authorize-preview", + canonical: "oa.auth_preview_attachment", + rpc: "auth_preview_attachment", + description: "批量授权当前用户预览审批单中的附件", + effect: "write", + }, + } + + root := NewRootCommand() + for _, test := range tests { + t.Run(test.rpc, func(t *testing.T) { + command := exactCommandForTest(root, test.cliPath) + if command == nil { + t.Fatalf("executable command %q is missing", test.cliPath) + } + + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"schema", test.cliPath, "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String()) + } + + var tool map[string]any + if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil { + t.Fatalf("decode delivery schema leaf: %v", err) + } + if got := schemaContractString(tool["canonical_path"]); got != test.canonical { + t.Fatalf("canonical_path = %q, want %q", got, test.canonical) + } + if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath { + t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath) + } + if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) { + t.Fatalf("description = %q, want prefix %q", got, test.description) + } + if got := schemaContractString(tool["interface_mode"]); got != "mcp" { + t.Fatalf("interface_mode = %q, want mcp", got) + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Fatalf("availability = %q, want available", got) + } + interfaceRef := schemaInterfaceObject(tool["interface_ref"]) + if got := schemaContractString(interfaceRef["product_id"]); got != "oa" { + t.Fatalf("interface_ref.product_id = %q, want oa", got) + } + if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc { + t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc) + } + if got := schemaContractString(tool["effect"]); got != test.effect { + t.Fatalf("effect = %q, want %q", got, test.effect) + } + 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) + } + if got := schemaContractString(tool["idempotency"]); got != "idempotent" { + t.Fatalf("idempotency = %q, want idempotent", got) + } + if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" { + t.Fatal(problem) + } + }) + } +} diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 87413498..3f57cc81 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -49,7 +49,8 @@ func oaFormValues(raw string) ([]map[string]string, error) { // get_processInstance_records, list_initiated_instances, list_pending_tasks, // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, -// start_process_instance +// start_process_instance, get_attachment_download_url, auth_download_file, +// auth_preview_attachment // ────────────────────────────────────────────────────────── func newOaCommand() *cobra.Command { @@ -58,9 +59,10 @@ func newOaCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "oa", Selection: contract.ProductSelectionDecl{ - AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送与评论", + AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送、评论与附件授权", UseWhen: []string{ "查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时", + "获取审批附件下载链接,或为当前用户授权下载、预览审批附件时", }, AvoidWhen: []string{ "不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume", @@ -70,7 +72,7 @@ func newOaCommand() *cobra.Command { root := &cobra.Command{ Use: "oa", Short: "OA 审批 / 同意 / 拒绝 / 撤销", - Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`, + Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`, RunE: groupRunE, } @@ -1376,6 +1378,7 @@ func newOaCommand() *cobra.Command { approvalForecastCmd, approvalCreateCmd, ) + approvalCmd.AddCommand(newOAAttachmentCommand()) root.AddCommand(approvalCmd) return root diff --git a/internal/helpers/oa_attachment.go b/internal/helpers/oa_attachment.go new file mode 100644 index 00000000..6994d2d6 --- /dev/null +++ b/internal/helpers/oa_attachment.go @@ -0,0 +1,268 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var items []map[string]json.RawMessage + if err := decoder.Decode(&items); err != nil { + return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err) + } + if err := rejectTrailingOAAttachmentJSON(decoder); err != nil { + return nil, err + } + if len(items) < 1 || len(items) > 10 { + return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件") + } + + infos := make([]map[string]any, 0, len(items)) + for index, item := range items { + for name := range item { + if name != "spaceId" && name != "fileId" { + return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name) + } + } + + spaceRaw, ok := item["spaceId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1) + } + spaceDecoder := json.NewDecoder(strings.NewReader(string(spaceRaw))) + spaceDecoder.UseNumber() + var spaceValue any + if err := spaceDecoder.Decode(&spaceValue); err != nil { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 不是有效数字: %w", index+1, err) + } + spaceID, ok := spaceValue.(json.Number) + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1) + } + + fileRaw, ok := item["fileId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1) + } + var fileID string + if err := json.Unmarshal(fileRaw, &fileID); err != nil { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串: %w", index+1, err) + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1) + } + + infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID}) + } + return infos, nil +} + +func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("--file-infos JSON 包含多余内容") + } + return fmt.Errorf("--file-infos JSON 解析失败: %w", err) + } + return nil +} + +func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error { + raw, _ := cmd.Flags().GetString("file-infos") + _, err := parseOAAttachmentFileInfos(raw) + return err +} + +func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { + fileIDs, _ := cmd.Flags().GetStringSlice("file-ids") + if len(fileIDs) > 20 { + return fmt.Errorf("--file-ids 最多包含 20 个附件 ID") + } + for index, fileID := range fileIDs { + if strings.TrimSpace(fileID) == "" { + return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1) + } + } + return nil +} + +func newOAAttachmentCommand() *cobra.Command { + attachmentCmd := &cobra.Command{ + Use: "attachment", + Short: "审批附件授权与下载链接", + RunE: groupRunE, + } + + downloadURLCmd := NewLeafCommand(LeafSpec{ + Use: "download-url", + Short: "获取审批附件下载链接", + Example: " dws oa approval attachment download-url --instance-id --file-id ", + Server: "oa", + Tool: "get_attachment_download_url", + Call: func(_ *cobra.Command, tool string, args map[string]any) error { + jsonOutput := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") + return callMCPToolInternalOpts("oa", tool, args, jsonOutput) + }, + Flags: []LeafFlag{ + {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true}, + {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_attachment_download_url", + CanonicalPath: "oa.get_attachment_download_url", + CLIPath: "oa approval attachment download-url", + PrimaryCLIPath: "oa approval attachment download-url", + }, + Description: "获取审批附件下载授权并生成临时下载链接", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取审批实例中指定附件的临时下载链接", + UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"}, + AvoidWhen: []string{ + "只需查看审批表单和附件元数据时使用 dws oa approval detail", + "需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接", + }, + Examples: []string{"dws oa approval attachment download-url --instance-id --file-id "}, + }, + Parameters: []contract.ParamDecl{ + {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, + {Name: "file-id", Property: "fileId", InterfaceType: "string"}, + {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, + }, + }, + }) + + authorizeDownloadCmd := NewLeafCommand(LeafSpec{ + Use: "authorize-download", + Short: "授权当前用户下载审批钉盘文件", + Long: "批量授权当前用户下载指定的审批钉盘文件。", + Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`, + Server: "oa", + Tool: "auth_download_file", + Flags: []LeafFlag{ + { + Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)", + Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true, + Transform: func(raw string) (any, error) { + return parseOAAttachmentFileInfos(raw) + }, + }, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, Flags: []string{"file-infos"}, + Description: "文件信息列表必须包含 1 至 10 项", + }}, + Validate: validateOAAttachmentFileInfos, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "auth_download_file", + CanonicalPath: "oa.auth_download_file", + CLIPath: "oa approval attachment authorize-download", + PrimaryCLIPath: "oa approval attachment authorize-download", + }, + Description: "批量授权当前用户下载指定的审批钉盘文件", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件", + UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"}, + AvoidWhen: []string{ + "需要生成单个审批附件下载链接时使用 attachment download-url", + "需要授权在审批单内预览附件时使用 attachment authorize-preview", + }, + Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "file-infos", Property: "fileInfos", InterfaceType: "array"}, + }, + }, + }) + + authorizePreviewCmd := NewLeafCommand(LeafSpec{ + Use: "authorize-preview", + Short: "授权当前用户预览审批附件", + Long: "批量授权当前用户预览审批单中的附件。", + Example: " dws oa approval attachment authorize-preview --instance-id --file-ids ,", + Server: "oa", + Tool: "auth_preview_attachment", + Flags: []LeafFlag{ + {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true}, + {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, Flags: []string{"file-ids"}, + Description: "附件 ID 列表最多包含 20 项且每项不能为空", + }}, + Validate: validateOAPreviewFileIDs, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "auth_preview_attachment", + CanonicalPath: "oa.auth_preview_attachment", + CLIPath: "oa approval attachment authorize-preview", + PrimaryCLIPath: "oa approval attachment authorize-preview", + }, + Description: "批量授权当前用户预览审批单中的附件", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件", + UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"}, + AvoidWhen: []string{ + "需要下载权限而不是预览权限时使用 attachment authorize-download", + "需要直接获得单个附件临时下载链接时使用 attachment download-url", + }, + Examples: []string{"dws oa approval attachment authorize-preview --instance-id --file-ids ,"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, + {Name: "file-ids", Property: "fileIdList", InterfaceType: "array"}, + {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, + }, + }, + }) + + attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd) + return attachmentCmd +} diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go new file mode 100644 index 00000000..b17034ef --- /dev/null +++ b/internal/helpers/oa_attachment_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "bytes" + "encoding/json" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToolCaller, args ...string) (string, error) { + t.Helper() + testseam.Protect(t, &deps) + testseam.Swap(t, &os.Args, []string{"dws", "oa"}) + InitDeps(caller) + var stdout bytes.Buffer + deps.Out.w = &stdout + deps.Out.errW = io.Discard + + cmd := newOaCommand() + cmd.PersistentFlags().Bool("yes", false, "跳过确认") + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), err +} + +func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *testing.T) { + const response = `{"result":{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"},"success":true}` + commandArgs := []string{ + "approval", "attachment", "download-url", + "--instance-id", "instance-1", + "--file-id", "file-1", + } + wantArgs := map[string]any{ + "processInstanceId": "instance-1", + "fileId": "file-1", + } + + 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 != "get_attachment_download_url" { + t.Fatalf("called %s/%s, want oa/get_attachment_download_url", caller.server, caller.tool) + } + if !reflect.DeepEqual(caller.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) + } + if !strings.Contains(stdout, "&OSSAccessKeyId=") || !strings.Contains(stdout, "&Signature=") { + t.Fatalf("stdout does not preserve signed query separators: %q", stdout) + } + if strings.Contains(stdout, `\u0026`) { + t.Fatalf("stdout contains escaped query separator: %q", stdout) + } + }) + + t.Run("raw compatibility", func(t *testing.T) { + caller := &scriptedToolCaller{format: "raw", steps: []scriptedToolStep{{text: response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + if stdout != response+"\n" { + t.Fatalf("stdout = %q, want %q", stdout, response+"\n") + } + }) +} + +func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + check func(*testing.T, map[string]any) + }{ + { + name: "download URL", + args: []string{ + "approval", "attachment", "download-url", + "--instance-id", "instance-1", + "--file-id", "file-1", + "--with-comment-attachment=false", + }, + wantTool: "get_attachment_download_url", + check: func(t *testing.T, got map[string]any) { + t.Helper() + if got["processInstanceId"] != "instance-1" || got["fileId"] != "file-1" { + t.Fatalf("download identifiers = %#v", got) + } + if value, ok := got["withCommentAttachment"].(bool); !ok || value { + t.Fatalf("withCommentAttachment = %#v, want explicit false", got["withCommentAttachment"]) + } + }, + }, + { + name: "download authorization", + args: []string{ + "approval", "attachment", "authorize-download", + "--file-infos", `[{"spaceId":27827223951,"fileId":"file-2"}]`, + }, + wantTool: "auth_download_file", + check: func(t *testing.T, got map[string]any) { + t.Helper() + infos, ok := got["fileInfos"].([]map[string]any) + if !ok || len(infos) != 1 { + t.Fatalf("fileInfos = %#v", got["fileInfos"]) + } + if infos[0]["spaceId"] != json.Number("27827223951") || infos[0]["fileId"] != "file-2" { + t.Fatalf("fileInfos[0] = %#v", infos[0]) + } + }, + }, + { + name: "preview authorization", + args: []string{ + "approval", "attachment", "authorize-preview", + "--instance-id", "instance-2", + "--file-ids", "file-3,file-4", + "--with-comment-attachment", + }, + wantTool: "auth_preview_attachment", + check: func(t *testing.T, got map[string]any) { + t.Helper() + if got["processInstanceId"] != "instance-2" || got["withCommentAttachment"] != true { + t.Fatalf("preview scalar payload = %#v", got) + } + ids, ok := got["fileIdList"].([]string) + if !ok || len(ids) != 2 || ids[0] != "file-3" || ids[1] != "file-4" { + t.Fatalf("fileIdList = %#v", got["fileIdList"]) + } + }, + }, + { + name: "optional boolean omitted", + args: []string{ + "approval", "attachment", "download-url", + "--instance-id", "instance-3", + "--file-id", "file-5", + }, + wantTool: "get_attachment_download_url", + check: func(t *testing.T, got map[string]any) { + t.Helper() + if _, exists := got["withCommentAttachment"]; exists { + t.Fatalf("optional withCommentAttachment should be omitted: %#v", got) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := executeOACommand(t, caller, test.args...); err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.server != "oa" || caller.tool != test.wantTool { + t.Fatalf("called %s/%s, want oa/%s", caller.server, caller.tool, test.wantTool) + } + test.check(t, caller.args) + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsInvalidFileInfos(t *testing.T) { + valid := `{"spaceId":27827223951,"fileId":"file-1"}` + tests := []struct { + name string + raw string + }{ + {name: "malformed JSON", raw: `[{`}, + {name: "null", raw: `null`}, + {name: "not array", raw: `{}`}, + {name: "empty array", raw: `[]`}, + {name: "more than ten", raw: `[` + strings.Repeat(valid+`,`, 10) + valid + `]`}, + {name: "missing space ID", raw: `[{"fileId":"file-1"}]`}, + {name: "string space ID", raw: `[{"spaceId":"27827223951","fileId":"file-1"}]`}, + {name: "missing file ID", raw: `[{"spaceId":27827223951}]`}, + {name: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`}, + {name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`}, + {name: "trailing JSON", raw: `[` + valid + `] {}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "attachment", "authorize-download", + "--file-infos", test.raw, + ) + if err == nil { + t.Fatalf("fileInfos %q unexpectedly succeeded", test.raw) + } + if caller.calls != 0 { + t.Fatalf("invalid fileInfos made %d MCP call(s)", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "separator only", raw: `,`}, + {name: "blank item", raw: `file-1, ,file-2`}, + {name: "more than twenty", raw: strings.Repeat("file,", 20) + "file"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "attachment", "authorize-preview", + "--instance-id", "instance-1", + "--file-ids", test.raw, + ) + if err == nil { + t.Fatalf("file IDs %q unexpectedly succeeded", test.raw) + } + if caller.calls != 0 { + t.Fatalf("invalid file IDs made %d MCP call(s)", caller.calls) + } + }) + } +} From 857279e076fbc91b0b36585547d04aec18a34a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Mon, 17 Aug 2026 14:03:42 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=B0=86=E9=99=84=E4=BB=B6=E7=9B=B8?= =?UTF-8?q?=E5=85=B3dws=E8=BF=81=E7=A7=BB=E5=88=B0oa.go=E4=B8=AD=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=85skill=E6=8F=8F=E8=BF=B0=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/helpers/oa.go | 253 +++++++++++++++++ internal/helpers/oa_attachment.go | 268 ------------------ skills/mono/references/products/oa.md | 54 ++++ skills/multi/dingtalk-misc/references/oa.md | 54 ++++ .../oa_attachment_delivery_policy_test.go | 64 +++++ 5 files changed, 425 insertions(+), 268 deletions(-) delete mode 100644 internal/helpers/oa_attachment.go create mode 100644 test/unit/oa_attachment_delivery_policy_test.go diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 3f57cc81..2104ba3a 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -42,6 +43,258 @@ func oaFormValues(raw string) ([]map[string]string, error) { return result, nil } +func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var items []map[string]json.RawMessage + if err := decoder.Decode(&items); err != nil { + return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err) + } + if err := rejectTrailingOAAttachmentJSON(decoder); err != nil { + return nil, err + } + if len(items) < 1 || len(items) > 10 { + return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件") + } + + infos := make([]map[string]any, 0, len(items)) + for index, item := range items { + for name := range item { + if name != "spaceId" && name != "fileId" { + return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name) + } + } + + spaceRaw, ok := item["spaceId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1) + } + spaceDecoder := json.NewDecoder(strings.NewReader(string(spaceRaw))) + spaceDecoder.UseNumber() + var spaceValue any + if err := spaceDecoder.Decode(&spaceValue); err != nil { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 不是有效数字: %w", index+1, err) + } + spaceID, ok := spaceValue.(json.Number) + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1) + } + + fileRaw, ok := item["fileId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1) + } + var fileID string + if err := json.Unmarshal(fileRaw, &fileID); err != nil { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串: %w", index+1, err) + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1) + } + + infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID}) + } + return infos, nil +} + +func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("--file-infos JSON 包含多余内容") + } + return fmt.Errorf("--file-infos JSON 解析失败: %w", err) + } + return nil +} + +func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error { + raw, _ := cmd.Flags().GetString("file-infos") + _, err := parseOAAttachmentFileInfos(raw) + return err +} + +func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { + fileIDs, _ := cmd.Flags().GetStringSlice("file-ids") + if len(fileIDs) > 20 { + return fmt.Errorf("--file-ids 最多包含 20 个附件 ID") + } + for index, fileID := range fileIDs { + if strings.TrimSpace(fileID) == "" { + return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1) + } + } + return nil +} + +func newOAAttachmentCommand() *cobra.Command { + attachmentCmd := &cobra.Command{ + Use: "attachment", + Short: "审批附件授权与下载链接", + RunE: groupRunE, + } + + downloadURLCmd := NewLeafCommand(LeafSpec{ + Use: "download-url", + Short: "获取审批附件下载链接", + Example: " dws oa approval attachment download-url --instance-id --file-id ", + Server: "oa", + Tool: "get_attachment_download_url", + Call: func(_ *cobra.Command, tool string, args map[string]any) error { + jsonOutput := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") + return callMCPToolInternalOpts("oa", tool, args, jsonOutput) + }, + Flags: []LeafFlag{ + {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true}, + {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_attachment_download_url", + CanonicalPath: "oa.get_attachment_download_url", + CLIPath: "oa approval attachment download-url", + PrimaryCLIPath: "oa approval attachment download-url", + }, + Description: "获取审批附件下载授权并生成临时下载链接", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取审批实例中指定附件的临时下载链接", + UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"}, + AvoidWhen: []string{ + "只需查看审批表单和附件元数据时使用 dws oa approval detail", + "需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接", + }, + Examples: []string{"dws oa approval attachment download-url --instance-id --file-id "}, + }, + Parameters: []contract.ParamDecl{ + {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, + {Name: "file-id", Property: "fileId", InterfaceType: "string"}, + {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, + }, + }, + }) + + authorizeDownloadCmd := NewLeafCommand(LeafSpec{ + Use: "authorize-download", + Short: "授权当前用户下载审批钉盘文件", + Long: "批量授权当前用户下载指定的审批钉盘文件。", + Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`, + Server: "oa", + Tool: "auth_download_file", + Flags: []LeafFlag{ + { + Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)", + Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true, + Transform: func(raw string) (any, error) { + return parseOAAttachmentFileInfos(raw) + }, + }, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, Flags: []string{"file-infos"}, + Description: "文件信息列表必须包含 1 至 10 项", + }}, + Validate: validateOAAttachmentFileInfos, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "auth_download_file", + CanonicalPath: "oa.auth_download_file", + CLIPath: "oa approval attachment authorize-download", + PrimaryCLIPath: "oa approval attachment authorize-download", + }, + Description: "批量授权当前用户下载指定的审批钉盘文件", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件", + UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"}, + AvoidWhen: []string{ + "需要生成单个审批附件下载链接时使用 attachment download-url", + "需要授权在审批单内预览附件时使用 attachment authorize-preview", + }, + Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "file-infos", Property: "fileInfos", InterfaceType: "array"}, + }, + }, + }) + + authorizePreviewCmd := NewLeafCommand(LeafSpec{ + Use: "authorize-preview", + Short: "授权当前用户预览审批附件", + Long: "批量授权当前用户预览审批单中的附件。", + Example: " dws oa approval attachment authorize-preview --instance-id --file-ids ,", + Server: "oa", + Tool: "auth_preview_attachment", + Flags: []LeafFlag{ + {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true}, + {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, Flags: []string{"file-ids"}, + Description: "附件 ID 列表最多包含 20 项且每项不能为空", + }}, + Validate: validateOAPreviewFileIDs, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "auth_preview_attachment", + CanonicalPath: "oa.auth_preview_attachment", + CLIPath: "oa approval attachment authorize-preview", + PrimaryCLIPath: "oa approval attachment authorize-preview", + }, + Description: "批量授权当前用户预览审批单中的附件", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件", + UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"}, + AvoidWhen: []string{ + "需要下载权限而不是预览权限时使用 attachment authorize-download", + "需要直接获得单个附件临时下载链接时使用 attachment download-url", + }, + Examples: []string{"dws oa approval attachment authorize-preview --instance-id --file-ids ,"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, + {Name: "file-ids", Property: "fileIdList", InterfaceType: "array"}, + {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, + }, + }, + }) + + attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd) + return attachmentCmd +} + // ────────────────────────────────────────────────────────── // dws oa — OA 审批 // MCP tools(tools/list): list_pending_approvals, get_processInstance_detail, diff --git a/internal/helpers/oa_attachment.go b/internal/helpers/oa_attachment.go deleted file mode 100644 index 6994d2d6..00000000 --- a/internal/helpers/oa_attachment.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); - -package helpers - -import ( - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/spf13/cobra" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" -) - -func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { - decoder := json.NewDecoder(strings.NewReader(raw)) - decoder.UseNumber() - var items []map[string]json.RawMessage - if err := decoder.Decode(&items); err != nil { - return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err) - } - if err := rejectTrailingOAAttachmentJSON(decoder); err != nil { - return nil, err - } - if len(items) < 1 || len(items) > 10 { - return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件") - } - - infos := make([]map[string]any, 0, len(items)) - for index, item := range items { - for name := range item { - if name != "spaceId" && name != "fileId" { - return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name) - } - } - - spaceRaw, ok := item["spaceId"] - if !ok { - return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1) - } - spaceDecoder := json.NewDecoder(strings.NewReader(string(spaceRaw))) - spaceDecoder.UseNumber() - var spaceValue any - if err := spaceDecoder.Decode(&spaceValue); err != nil { - return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 不是有效数字: %w", index+1, err) - } - spaceID, ok := spaceValue.(json.Number) - if !ok { - return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1) - } - - fileRaw, ok := item["fileId"] - if !ok { - return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1) - } - var fileID string - if err := json.Unmarshal(fileRaw, &fileID); err != nil { - return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串: %w", index+1, err) - } - fileID = strings.TrimSpace(fileID) - if fileID == "" { - return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1) - } - - infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID}) - } - return infos, nil -} - -func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error { - var extra any - if err := decoder.Decode(&extra); err != io.EOF { - if err == nil { - return fmt.Errorf("--file-infos JSON 包含多余内容") - } - return fmt.Errorf("--file-infos JSON 解析失败: %w", err) - } - return nil -} - -func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error { - raw, _ := cmd.Flags().GetString("file-infos") - _, err := parseOAAttachmentFileInfos(raw) - return err -} - -func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { - fileIDs, _ := cmd.Flags().GetStringSlice("file-ids") - if len(fileIDs) > 20 { - return fmt.Errorf("--file-ids 最多包含 20 个附件 ID") - } - for index, fileID := range fileIDs { - if strings.TrimSpace(fileID) == "" { - return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1) - } - } - return nil -} - -func newOAAttachmentCommand() *cobra.Command { - attachmentCmd := &cobra.Command{ - Use: "attachment", - Short: "审批附件授权与下载链接", - RunE: groupRunE, - } - - downloadURLCmd := NewLeafCommand(LeafSpec{ - Use: "download-url", - Short: "获取审批附件下载链接", - Example: " dws oa approval attachment download-url --instance-id --file-id ", - Server: "oa", - Tool: "get_attachment_download_url", - Call: func(_ *cobra.Command, tool string, args map[string]any) error { - jsonOutput := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") - return callMCPToolInternalOpts("oa", tool, args, jsonOutput) - }, - Flags: []LeafFlag{ - {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, - {Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true}, - {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, - }, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: LeafContract{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "get_attachment_download_url", - CanonicalPath: "oa.get_attachment_download_url", - CLIPath: "oa approval attachment download-url", - PrimaryCLIPath: "oa approval attachment download-url", - }, - Description: "获取审批附件下载授权并生成临时下载链接", - Interface: &contract.InterfaceSpec{ - Mode: "mcp", - Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"}, - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取审批实例中指定附件的临时下载链接", - UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"}, - AvoidWhen: []string{ - "只需查看审批表单和附件元数据时使用 dws oa approval detail", - "需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接", - }, - Examples: []string{"dws oa approval attachment download-url --instance-id --file-id "}, - }, - Parameters: []contract.ParamDecl{ - {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, - {Name: "file-id", Property: "fileId", InterfaceType: "string"}, - {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, - }, - }, - }) - - authorizeDownloadCmd := NewLeafCommand(LeafSpec{ - Use: "authorize-download", - Short: "授权当前用户下载审批钉盘文件", - Long: "批量授权当前用户下载指定的审批钉盘文件。", - Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`, - Server: "oa", - Tool: "auth_download_file", - Flags: []LeafFlag{ - { - Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)", - Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true, - Transform: func(raw string) (any, error) { - return parseOAAttachmentFileInfos(raw) - }, - }, - }, - Constraints: []LeafConstraint{{ - Kind: corecmd.Custom, Flags: []string{"file-infos"}, - Description: "文件信息列表必须包含 1 至 10 项", - }}, - Validate: validateOAAttachmentFileInfos, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: LeafContract{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "auth_download_file", - CanonicalPath: "oa.auth_download_file", - CLIPath: "oa approval attachment authorize-download", - PrimaryCLIPath: "oa approval attachment authorize-download", - }, - Description: "批量授权当前用户下载指定的审批钉盘文件", - Interface: &contract.InterfaceSpec{ - Mode: "mcp", - Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"}, - }, - Selection: contract.SelectionSpec{ - AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件", - UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"}, - AvoidWhen: []string{ - "需要生成单个审批附件下载链接时使用 attachment download-url", - "需要授权在审批单内预览附件时使用 attachment authorize-preview", - }, - Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`}, - }, - Parameters: []contract.ParamDecl{ - {Name: "file-infos", Property: "fileInfos", InterfaceType: "array"}, - }, - }, - }) - - authorizePreviewCmd := NewLeafCommand(LeafSpec{ - Use: "authorize-preview", - Short: "授权当前用户预览审批附件", - Long: "批量授权当前用户预览审批单中的附件。", - Example: " dws oa approval attachment authorize-preview --instance-id --file-ids ,", - Server: "oa", - Tool: "auth_preview_attachment", - Flags: []LeafFlag{ - {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, - {Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true}, - {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, - }, - Constraints: []LeafConstraint{{ - Kind: corecmd.Custom, Flags: []string{"file-ids"}, - Description: "附件 ID 列表最多包含 20 项且每项不能为空", - }}, - Validate: validateOAPreviewFileIDs, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: LeafContract{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "auth_preview_attachment", - CanonicalPath: "oa.auth_preview_attachment", - CLIPath: "oa approval attachment authorize-preview", - PrimaryCLIPath: "oa approval attachment authorize-preview", - }, - Description: "批量授权当前用户预览审批单中的附件", - Interface: &contract.InterfaceSpec{ - Mode: "mcp", - Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"}, - }, - Selection: contract.SelectionSpec{ - AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件", - UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"}, - AvoidWhen: []string{ - "需要下载权限而不是预览权限时使用 attachment authorize-download", - "需要直接获得单个附件临时下载链接时使用 attachment download-url", - }, - Examples: []string{"dws oa approval attachment authorize-preview --instance-id --file-ids ,"}, - }, - Parameters: []contract.ParamDecl{ - {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, - {Name: "file-ids", Property: "fileIdList", InterfaceType: "array"}, - {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, - }, - }, - }) - - attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd) - return attachmentCmd -} diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 46c32b36..cc82079f 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -27,6 +27,57 @@ Flags: --instance-id string 审批实例 ID (必填) ``` +### 审批附件授权与下载 + +先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: + +- 需要单个附件的临时下载链接:`attachment download-url` +- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` +- 需要在审批场景内批量预览附件:`attachment authorize-preview` + +#### 获取审批附件临时下载链接 + +``` +Usage: + dws oa approval attachment download-url [flags] +Example: + dws oa approval attachment download-url --instance-id --file-id --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-id string 审批附件文件 ID (必填) + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只返回临时下载链接,不会自动保存文件。链接包含 OSS 签名参数,应在生成后立即使用;JSON 输出中的 `&` 是签名参数分隔符,复制链接时必须完整保留。附件来自审批评论时增加 `--with-comment-attachment`。 + +#### 批量授权下载审批钉盘文件 + +``` +Usage: + dws oa approval attachment authorize-download [flags] +Example: + dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]' --format json +Flags: + --file-infos string 文件信息 JSON 数组 (必填),每项包含数字类型 spaceId 和字符串类型 fileId,最多 10 项 +``` + +该命令为当前用户开通文件下载权限,但不返回下载链接。需要链接时继续调用 `attachment download-url`。 + +#### 批量授权预览审批附件 + +``` +Usage: + dws oa approval attachment authorize-preview [flags] +Example: + dws oa approval attachment authorize-preview --instance-id --file-ids , --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-ids strings 附件 ID 列表,逗号分隔 (必填),最多 20 项 + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -667,6 +718,9 @@ Flags: 用户说"待审批/待处理审批" → `approval list-pending` 用户说"审批详情/看审批" → `approval detail` +用户说"下载审批附件/获取审批附件下载链接" → `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) 用户说"同意审批/批准" → 先 `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 4b5e6205..7124e064 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -31,6 +31,57 @@ Flags: --instance-id string 审批实例 ID (必填) ``` +### 审批附件授权与下载 + +先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: + +- 需要单个附件的临时下载链接:`attachment download-url` +- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` +- 需要在审批场景内批量预览附件:`attachment authorize-preview` + +#### 获取审批附件临时下载链接 + +``` +Usage: + dws oa approval attachment download-url [flags] +Example: + dws oa approval attachment download-url --instance-id --file-id --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-id string 审批附件文件 ID (必填) + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只返回临时下载链接,不会自动保存文件。链接包含 OSS 签名参数,应在生成后立即使用;JSON 输出中的 `&` 是签名参数分隔符,复制链接时必须完整保留。附件来自审批评论时增加 `--with-comment-attachment`。 + +#### 批量授权下载审批钉盘文件 + +``` +Usage: + dws oa approval attachment authorize-download [flags] +Example: + dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]' --format json +Flags: + --file-infos string 文件信息 JSON 数组 (必填),每项包含数字类型 spaceId 和字符串类型 fileId,最多 10 项 +``` + +该命令为当前用户开通文件下载权限,但不返回下载链接。需要链接时继续调用 `attachment download-url`。 + +#### 批量授权预览审批附件 + +``` +Usage: + dws oa approval attachment authorize-preview [flags] +Example: + dws oa approval attachment authorize-preview --instance-id --file-ids , --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-ids strings 附件 ID 列表,逗号分隔 (必填),最多 20 项 + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -670,6 +721,9 @@ Flags: - 示例:"有没有外出申请的审批" → `approval list-pending --query 外出申请` - 示例:"待审批"(无关键词)→ `approval list-pending` 用户说"审批详情/看审批" → `approval detail` +用户说"下载审批附件/获取审批附件下载链接" → `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) 用户说"同意审批/批准" → 先 `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 new file mode 100644 index 00000000..32af42ec --- /dev/null +++ b/test/unit/oa_attachment_delivery_policy_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package unit_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + + attachmentFile := filepath.Join(root, "internal", "helpers", "oa_attachment.go") + if _, err := os.Stat(attachmentFile); !os.IsNotExist(err) { + t.Fatalf("OA attachment commands must live in internal/helpers/oa.go; separate file still exists: %s", attachmentFile) + } + + oaSourcePath := filepath.Join(root, "internal", "helpers", "oa.go") + oaSource, err := os.ReadFile(oaSourcePath) + if err != nil { + t.Fatalf("read %s: %v", oaSourcePath, err) + } + for _, required := range []string{ + "func newOAAttachmentCommand()", + `Tool: "get_attachment_download_url"`, + `Tool: "auth_download_file"`, + `Tool: "auth_preview_attachment"`, + } { + if !strings.Contains(string(oaSource), required) { + t.Errorf("%s missing attachment declaration %q", oaSourcePath, required) + } + } + + docPaths := []string{ + filepath.Join(root, "skills", "mono", "references", "products", "oa.md"), + filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "oa.md"), + } + for _, docPath := range docPaths { + doc, err := os.ReadFile(docPath) + if err != nil { + t.Fatalf("read %s: %v", docPath, err) + } + for _, required := range []string{ + "dws oa approval attachment download-url", + "dws oa approval attachment authorize-download", + "dws oa approval attachment authorize-preview", + "临时下载链接", + "最多 10", + "最多 20", + } { + if !strings.Contains(string(doc), required) { + t.Errorf("%s missing OA attachment Skill guidance %q", docPath, required) + } + } + } +} From f319906f29976382936aad0726d54756bd1d7ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Mon, 17 Aug 2026 17:17:29 +0800 Subject: [PATCH 3/6] fix(oa): close attachment coverage gaps to #666 --- internal/helpers/oa.go | 18 ++++++------------ internal/helpers/oa_attachment_test.go | 2 ++ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 2104ba3a..68a73ae0 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -46,7 +46,7 @@ func oaFormValues(raw string) ([]map[string]string, error) { func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { decoder := json.NewDecoder(strings.NewReader(raw)) decoder.UseNumber() - var items []map[string]json.RawMessage + var items []map[string]any if err := decoder.Decode(&items); err != nil { return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err) } @@ -65,28 +65,22 @@ func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { } } - spaceRaw, ok := item["spaceId"] + spaceValue, ok := item["spaceId"] if !ok { return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1) } - spaceDecoder := json.NewDecoder(strings.NewReader(string(spaceRaw))) - spaceDecoder.UseNumber() - var spaceValue any - if err := spaceDecoder.Decode(&spaceValue); err != nil { - return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 不是有效数字: %w", index+1, err) - } spaceID, ok := spaceValue.(json.Number) if !ok { return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1) } - fileRaw, ok := item["fileId"] + fileValue, ok := item["fileId"] if !ok { return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1) } - var fileID string - if err := json.Unmarshal(fileRaw, &fileID); err != nil { - return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串: %w", index+1, err) + fileID, ok := fileValue.(string) + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串", index+1) } fileID = strings.TrimSpace(fileID) if fileID == "" { diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index b17034ef..b32ec111 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -188,9 +188,11 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidFileInfos(t *testing.T) {name: "missing space ID", raw: `[{"fileId":"file-1"}]`}, {name: "string space ID", raw: `[{"spaceId":"27827223951","fileId":"file-1"}]`}, {name: "missing file ID", raw: `[{"spaceId":27827223951}]`}, + {name: "numeric file ID", raw: `[{"spaceId":27827223951,"fileId":232271651278}]`}, {name: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`}, {name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`}, {name: "trailing JSON", raw: `[` + valid + `] {}`}, + {name: "malformed trailing JSON", raw: `[` + valid + `] {`}, } for _, test := range tests { From 721a40b05e3d4c60180866e74eee3ebfae0df43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Tue, 18 Aug 2026 10:41:21 +0800 Subject: [PATCH 4/6] fix(oa): declare attachment result contracts - add success and failure outcomes for three attachment commands - define business data schemas and mark downloadUri as sensitive - migrate attachment commands to unified result output - verify compact and full Schema result projections - cover success, malformed response, and tool error paths to #666 --- .../app/schema_oa_attachment_contract_test.go | 75 ++++++++++++- internal/helpers/oa.go | 75 +++++++++---- internal/helpers/oa_attachment_test.go | 106 +++++++++++++++++- 3 files changed, 225 insertions(+), 31 deletions(-) diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go index 82d48f04..75d40694 100644 --- a/internal/app/schema_oa_attachment_contract_test.go +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -6,17 +6,21 @@ package app import ( "bytes" "encoding/json" + "reflect" "strings" "testing" ) func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) { tests := []struct { - cliPath string - canonical string - rpc string - description string - effect string + cliPath string + canonical string + rpc string + description string + effect string + resultType string + resultFields map[string]string + sensitivePaths []string }{ { cliPath: "oa approval attachment download-url", @@ -24,6 +28,12 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t rpc: "get_attachment_download_url", description: "获取审批附件下载授权并生成临时下载链接", effect: "read", + resultType: "object", + resultFields: map[string]string{ + "spaceId": "integer", "agentId": "integer", "downloadUri": "string", + "class": "string", "fileId": "string", + }, + sensitivePaths: []string{"downloadUri"}, }, { cliPath: "oa approval attachment authorize-download", @@ -31,6 +41,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t rpc: "auth_download_file", description: "批量授权当前用户下载指定的审批钉盘文件", effect: "write", + resultType: "boolean", }, { cliPath: "oa approval attachment authorize-preview", @@ -38,12 +49,16 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t rpc: "auth_preview_attachment", description: "批量授权当前用户预览审批单中的附件", effect: "write", + resultType: "object", + resultFields: map[string]string{ + "spaceId": "integer", "agentId": "integer", "class": "string", + }, }, } - root := NewRootCommand() for _, test := range tests { t.Run(test.rpc, func(t *testing.T) { + root := NewRootCommand() command := exactCommandForTest(root, test.cliPath) if command == nil { t.Fatalf("executable command %q is missing", test.cliPath) @@ -95,9 +110,57 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t if got := schemaContractString(tool["idempotency"]); got != "idempotent" { t.Fatalf("idempotency = %q, want idempotent", got) } + fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths) + + stdout.Reset() + stderr.Reset() + root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String()) + } + var compactTool map[string]any + if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil { + t.Fatalf("decode compact delivery schema leaf: %v", err) + } + compactResult, ok := compactTool["result"].(map[string]any) + if !ok { + t.Fatalf("compact result = %#v, want object", compactTool["result"]) + } + if !reflect.DeepEqual(compactResult, fullResult) { + t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult) + } if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" { t.Fatal(problem) } }) } } + +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) + if !ok { + t.Fatalf("full result = %#v, want object", tool["result"]) + } + if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) { + t.Fatalf("result.outcomes = %#v, want %#v", got, want) + } + if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) { + t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths) + } + dataSchema, ok := result["data_schema"].(map[string]any) + if !ok || schemaContractString(dataSchema["type"]) != resultType { + t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType) + } + properties, _ := dataSchema["properties"].(map[string]any) + if len(properties) != len(fields) { + t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields) + } + for name, fieldType := range fields { + property, ok := properties[name].(map[string]any) + if !ok || schemaContractString(property["type"]) != fieldType { + t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType) + } + } + return result +} diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 68a73ae0..8dfe47b5 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -12,6 +12,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/spf13/cobra" ) @@ -122,6 +124,22 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { return nil } +func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args) + if err != nil { + return nil, err + } + response, ok := data.(map[string]any) + if !ok { + return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值不是 JSON 对象", tool)) + } + result, ok := response["result"] + if !ok { + return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值缺少 result", tool)) + } + return output.Success(result), nil +} + func newOAAttachmentCommand() *cobra.Command { attachmentCmd := &cobra.Command{ Use: "attachment", @@ -130,15 +148,13 @@ func newOAAttachmentCommand() *cobra.Command { } downloadURLCmd := NewLeafCommand(LeafSpec{ - Use: "download-url", - Short: "获取审批附件下载链接", - Example: " dws oa approval attachment download-url --instance-id --file-id ", - Server: "oa", - Tool: "get_attachment_download_url", - Call: func(_ *cobra.Command, tool string, args map[string]any) error { - jsonOutput := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") - return callMCPToolInternalOpts("oa", tool, args, jsonOutput) - }, + Use: "download-url", + Short: "获取审批附件下载链接", + Example: " dws oa approval attachment download-url --instance-id --file-id ", + Server: "oa", + Tool: "get_attachment_download_url", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, Flags: []LeafFlag{ {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, {Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true}, @@ -157,6 +173,11 @@ func newOAAttachmentCommand() *cobra.Command { PrimaryCLIPath: "oa approval attachment download-url", }, Description: "获取审批附件下载授权并生成临时下载链接", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件临时下载信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"downloadUri":{"type":"string","description":"带临时授权签名的附件下载链接"},"class":{"type":"string","description":"服务端响应类型标识"},"fileId":{"type":"string","description":"审批附件文件 ID"}},"required":["spaceId","agentId","downloadUri","fileId"],"additionalProperties":true}`), + SensitivePaths: []string{"downloadUri"}, + }, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", @@ -180,12 +201,14 @@ func newOAAttachmentCommand() *cobra.Command { }) authorizeDownloadCmd := NewLeafCommand(LeafSpec{ - Use: "authorize-download", - Short: "授权当前用户下载审批钉盘文件", - Long: "批量授权当前用户下载指定的审批钉盘文件。", - Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`, - Server: "oa", - Tool: "auth_download_file", + Use: "authorize-download", + Short: "授权当前用户下载审批钉盘文件", + Long: "批量授权当前用户下载指定的审批钉盘文件。", + Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`, + Server: "oa", + Tool: "auth_download_file", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, Flags: []LeafFlag{ { Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)", @@ -213,6 +236,10 @@ func newOAAttachmentCommand() *cobra.Command { PrimaryCLIPath: "oa approval attachment authorize-download", }, Description: "批量授权当前用户下载指定的审批钉盘文件", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"boolean","description":"是否成功为当前用户授予审批钉盘文件下载权限"}`), + }, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", @@ -234,12 +261,14 @@ func newOAAttachmentCommand() *cobra.Command { }) authorizePreviewCmd := NewLeafCommand(LeafSpec{ - Use: "authorize-preview", - Short: "授权当前用户预览审批附件", - Long: "批量授权当前用户预览审批单中的附件。", - Example: " dws oa approval attachment authorize-preview --instance-id --file-ids ,", - Server: "oa", - Tool: "auth_preview_attachment", + Use: "authorize-preview", + Short: "授权当前用户预览审批附件", + Long: "批量授权当前用户预览审批单中的附件。", + Example: " dws oa approval attachment authorize-preview --instance-id --file-ids ,", + Server: "oa", + Tool: "auth_preview_attachment", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, Flags: []LeafFlag{ {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, {Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true}, @@ -263,6 +292,10 @@ func newOAAttachmentCommand() *cobra.Command { PrimaryCLIPath: "oa approval attachment authorize-preview", }, Description: "批量授权当前用户预览审批单中的附件", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件预览授权信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["spaceId","agentId"],"additionalProperties":true}`), + }, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index b32ec111..230e9021 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -5,14 +5,18 @@ package helpers import ( "bytes" + "context" "encoding/json" + "errors" "io" "os" "reflect" "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" ) func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToolCaller, args ...string) (string, error) { @@ -26,6 +30,13 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo cmd := newOaCommand() cmd.PersistentFlags().Bool("yes", false, "跳过确认") + cmd.PersistentFlags().String("format", caller.Format(), "输出格式") + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(executed) + return err + } cmd.SilenceErrors = true cmd.SilenceUsage = true cmd.SetOut(&stdout) @@ -65,6 +76,17 @@ func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *tes if strings.Contains(stdout, `\u0026`) { t.Fatalf("stdout contains escaped query separator: %q", stdout) } + 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 || !strings.Contains(data["downloadUri"].(string), "&Signature=") { + t.Fatalf("unified data = %#v", envelope["data"]) + } }) t.Run("raw compatibility", func(t *testing.T) { @@ -73,12 +95,88 @@ func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *tes if err != nil { t.Fatalf("execute command: %v", err) } - if stdout != response+"\n" { - t.Fatalf("stdout = %q, want %q", stdout, response+"\n") + const want = `{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"}` + "\n" + if stdout != want { + t.Fatalf("stdout = %q, want %q", stdout, want) } }) } +func TestCrossPlatformCoverageOAAttachmentProjectsUnifiedBusinessData(t *testing.T) { + tests := []struct { + name string + args []string + response string + wantData any + }{ + { + name: "download authorization", + args: []string{"approval", "attachment", "authorize-download", "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`}, + response: `{"result":true,"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`, + wantData: true, + }, + { + name: "preview authorization", + args: []string{"approval", "attachment", "authorize-preview", "--instance-id", "instance-1", "--file-ids", "file-1"}, + response: `{"result":{"spaceId":27827223951,"agentId":4115627346,"class":"com.dingtalk.bpms.oapi.vo.AppSpaceResponse"},"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`, + wantData: map[string]any{ + "spaceId": float64(27827223951), "agentId": float64(4115627346), + "class": "com.dingtalk.bpms.oapi.vo.AppSpaceResponse", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: test.response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + 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" || !reflect.DeepEqual(envelope["data"], test.wantData) { + t.Fatalf("unified envelope = %#v, want data %#v", envelope, test.wantData) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsSuccessWithoutResult(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`}}} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "authorize-download", + "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`, + ) + if err == nil || !strings.Contains(err.Error(), "缺少 result") { + t.Fatalf("execute error = %v, want missing result", err) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsInvalidToolResult(t *testing.T) { + tests := []struct { + name string + step scriptedToolStep + want string + }{ + {name: "non-object response", step: scriptedToolStep{text: `[]`}, want: "不是 JSON 对象"}, + {name: "tool error", step: scriptedToolStep{err: errors.New("tool unavailable")}, want: "tool unavailable"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{test.step}} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "authorize-download", + "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`, + ) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("execute error = %v, want %q", err, test.want) + } + }) + } +} + func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) { tests := []struct { name string @@ -162,8 +260,8 @@ func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - caller := &scriptedToolCaller{} - if err := executeOACommand(t, caller, test.args...); err != nil { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}} + if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...); err != nil { t.Fatalf("execute command: %v", err) } if caller.server != "oa" || caller.tool != test.wantTool { From 7c76e4fc03dc4bc148f1c20b61bdcc7a5c54db52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Tue, 18 Aug 2026 11:23:51 +0800 Subject: [PATCH 5/6] test(oa): harden attachment delivery policy checks to #666 --- .../oa_attachment_delivery_policy_test.go | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go index 32af42ec..cd5f7cd7 100644 --- a/test/unit/oa_attachment_delivery_policy_test.go +++ b/test/unit/oa_attachment_delivery_policy_test.go @@ -4,9 +4,14 @@ package unit_test import ( + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" + "reflect" "runtime" + "strconv" "strings" "testing" ) @@ -24,19 +29,40 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { } oaSourcePath := filepath.Join(root, "internal", "helpers", "oa.go") - oaSource, err := os.ReadFile(oaSourcePath) + oaFile, err := parser.ParseFile(token.NewFileSet(), oaSourcePath, nil, 0) if err != nil { - t.Fatalf("read %s: %v", oaSourcePath, err) + t.Fatalf("parse %s: %v", oaSourcePath, err) } - for _, required := range []string{ - "func newOAAttachmentCommand()", - `Tool: "get_attachment_download_url"`, - `Tool: "auth_download_file"`, - `Tool: "auth_preview_attachment"`, - } { - if !strings.Contains(string(oaSource), required) { - t.Errorf("%s missing attachment declaration %q", oaSourcePath, required) + wantTools := map[string]string{ + "download-url": "get_attachment_download_url", + "authorize-download": "auth_download_file", + "authorize-preview": "auth_preview_attachment", + } + gotTools := make(map[string]string, len(wantTools)) + foundConstructor := false + for _, declaration := range oaFile.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != "newOAAttachmentCommand" { + continue } + foundConstructor = true + ast.Inspect(function.Body, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || !isOAAttachmentLeafSpec(literal) { + return true + } + use := oaAttachmentLeafStringField(t, literal, "Use") + if _, expected := wantTools[use]; expected { + gotTools[use] = oaAttachmentLeafStringField(t, literal, "Tool") + } + return true + }) + } + if !foundConstructor { + t.Fatalf("%s missing newOAAttachmentCommand", oaSourcePath) + } + if !reflect.DeepEqual(gotTools, wantTools) { + t.Fatalf("%s attachment Use/Tool declarations = %#v, want %#v", oaSourcePath, gotTools, wantTools) } docPaths := []string{ @@ -62,3 +88,32 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { } } } + +func isOAAttachmentLeafSpec(literal *ast.CompositeLit) bool { + identifier, ok := literal.Type.(*ast.Ident) + return ok && identifier.Name == "LeafSpec" +} + +func oaAttachmentLeafStringField(t *testing.T, literal *ast.CompositeLit, fieldName string) string { + t.Helper() + for _, element := range literal.Elts { + keyValue, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := keyValue.Key.(*ast.Ident) + if !ok || key.Name != fieldName { + continue + } + value, ok := keyValue.Value.(*ast.BasicLit) + if !ok || value.Kind != token.STRING { + t.Fatalf("LeafSpec.%s must be a string literal", fieldName) + } + decoded, err := strconv.Unquote(value.Value) + if err != nil { + t.Fatalf("decode LeafSpec.%s: %v", fieldName, err) + } + return decoded + } + return "" +} From f290a2101e8d8bf15a082f0fa502a760d6ac10e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Tue, 18 Aug 2026 19:06:01 +0800 Subject: [PATCH 6/6] fix drive.md to #666 --- skills/multi/dingtalk-drive/references/drive.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 61d7aecc..113beb05 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -91,3 +91,7 @@ dws drive +publish-unset --node 2. 分页、递归或过滤未完成时保留 cursor/truncated/errors,不包装成全量成功。 3. 写入超时或响应丢失先按 nodeId、名称、路径和大小回读,不能盲目重放。 4. 在线文档误入普通下载/覆盖时切 Doc、Sheet 或 AITable;不要用 Drive 重试改变内容。 + +## 辅助脚本 + +- [drive_tree_list.py](../scripts/drive_tree_list.py):递归列出钉盘目录树;普通浏览仍优先使用 `dws drive +list`。