Merge branch 'main' into codex/pr1035-drive-tree-orphan-fix

This commit is contained in:
pengzhihan47-star
2026-08-18 19:27:53 +08:00
committed by GitHub
8 changed files with 1077 additions and 3 deletions
@@ -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.
@@ -0,0 +1,166 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
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
resultType string
resultFields map[string]string
sensitivePaths []string
}{
{
cliPath: "oa approval attachment download-url",
canonical: "oa.get_attachment_download_url",
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",
canonical: "oa.auth_download_file",
rpc: "auth_download_file",
description: "批量授权当前用户下载指定的审批钉盘文件",
effect: "write",
resultType: "boolean",
},
{
cliPath: "oa approval attachment authorize-preview",
canonical: "oa.auth_preview_attachment",
rpc: "auth_preview_attachment",
description: "批量授权当前用户预览审批单中的附件",
effect: "write",
resultType: "object",
resultFields: map[string]string{
"spaceId": "integer", "agentId": "integer", "class": "string",
},
},
}
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)
}
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)
}
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
}
+286 -3
View File
@@ -10,7 +10,10 @@ 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"
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"
)
@@ -42,6 +45,283 @@ 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]any
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)
}
}
spaceValue, ok := item["spaceId"]
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1)
}
spaceID, ok := spaceValue.(json.Number)
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1)
}
fileValue, ok := item["fileId"]
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1)
}
fileID, ok := fileValue.(string)
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串", index+1)
}
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 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",
Short: "审批附件授权与下载链接",
RunE: groupRunE,
}
downloadURLCmd := NewLeafCommand(LeafSpec{
Use: "download-url",
Short: "获取审批附件下载链接",
Example: " dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>",
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},
{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: "获取审批附件下载授权并生成临时下载链接",
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",
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 <processInstanceId> --file-id <fileId>"},
},
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",
OutputRollout: output.RolloutUnifiedActive,
ResultCall: callOAAttachmentResult,
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: "批量授权当前用户下载指定的审批钉盘文件",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"boolean","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 <processInstanceId> --file-ids <fileId1>,<fileId2>",
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},
{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: "批量授权当前用户预览审批单中的附件",
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",
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 <processInstanceId> --file-ids <fileId1>,<fileId2>"},
},
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,
@@ -49,7 +329,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 +339,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 +352,7 @@ func newOaCommand() *cobra.Command {
root := &cobra.Command{
Use: "oa",
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`,
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
RunE: groupRunE,
}
@@ -1376,6 +1658,7 @@ func newOaCommand() *cobra.Command {
approvalForecastCmd,
approvalCreateCmd,
)
approvalCmd.AddCommand(newOAAttachmentCommand())
root.AddCommand(approvalCmd)
return root
+339
View File
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
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) {
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.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)
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)
}
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) {
caller := &scriptedToolCaller{format: "raw", steps: []scriptedToolStep{{text: response}}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
if err != nil {
t.Fatalf("execute command: %v", err)
}
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
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{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 {
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: "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 {
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)
}
})
}
}
+54
View File
@@ -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 <processInstanceId> --file-id <fileId> --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 <processInstanceId> --file-ids <fileId1>,<fileId2> --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`
@@ -91,3 +91,7 @@ dws drive +publish-unset --node <dentryUuid>
2. 分页、递归或过滤未完成时保留 cursor/truncated/errors,不包装成全量成功。
3. 写入超时或响应丢失先按 nodeId、名称、路径和大小回读,不能盲目重放。
4. 在线文档误入普通下载/覆盖时切 Doc、Sheet 或 AITable;不要用 Drive 重试改变内容。
## 辅助脚本
- [drive_tree_list.py](../scripts/drive_tree_list.py):递归列出钉盘目录树;普通浏览仍优先使用 `dws drive +list`。
@@ -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 <processInstanceId> --file-id <fileId> --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 <processInstanceId> --file-ids <fileId1>,<fileId2> --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`
@@ -0,0 +1,119 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package unit_test
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"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")
oaFile, err := parser.ParseFile(token.NewFileSet(), oaSourcePath, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", oaSourcePath, err)
}
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{
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)
}
}
}
}
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 ""
}