Merge branch 'main' into codex/pr1035-drive-tree-orphan-fix
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user