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