package plane_v1 import ( "encoding/json" "fmt" "go.uber.org/zap" ) // ==================== Work Item Attachment API ==================== // AttachmentCreateResponse 附件创建响应 type AttachmentCreateResponse struct { UploadData struct { URL string `json:"url"` Fields map[string]string `json:"fields"` } `json:"upload_data"` AssetId string `json:"asset_id"` AssetUrl string `json:"asset_url"` } // ListWorkItemAttachments 列出工作项附件 func ListWorkItemAttachments(workItemID string) ([]byte, error) { path := fmt.Sprintf("/api/v1/workspaces/%s/projects/%s/work-items/%s/attachments/", planeWorkspaceSlug, planeProjectId, workItemID) resp, err := planeHttpGet(path) if err != nil { zap.L().Error("[Plane] ListWorkItemAttachments 请求失败", zap.Error(err)) return nil, err } if !isSuccess(resp.StatusCode) { zap.L().Error("[Plane] ListWorkItemAttachments 返回错误", zap.String("status", resp.Status)) return nil, fmt.Errorf("ListWorkItemAttachments 失败: %s", resp.Status) } return resp.Body, nil } // CreateWorkItemAttachment 创建附件(获取预签名 URL) func CreateWorkItemAttachment(workItemID, fileName, mimeType string, size int64) (*AttachmentCreateResponse, error) { path := fmt.Sprintf("/api/v1/workspaces/%s/projects/%s/work-items/%s/attachments/", planeWorkspaceSlug, planeProjectId, workItemID) payload := map[string]interface{}{ "name": fileName, "type": mimeType, "size": size, } resp, err := planeHttpPost(path, payload) if err != nil { zap.L().Error("[Plane] CreateWorkItemAttachment 请求失败", zap.Error(err)) return nil, err } if !isSuccess(resp.StatusCode) { zap.L().Error("[Plane] CreateWorkItemAttachment 返回错误", zap.String("status", resp.Status), zap.String("body", string(resp.Body))) return nil, fmt.Errorf("CreateWorkItemAttachment 失败: %s", resp.Status) } var result AttachmentCreateResponse if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("解析附件创建响应失败: %w", err) } return &result, nil } // ConfirmWorkItemAttachment 确认附件上传完成 func ConfirmWorkItemAttachment(workItemID, assetID string) error { path := fmt.Sprintf("/api/v1/workspaces/%s/projects/%s/work-items/%s/attachments/%s/", planeWorkspaceSlug, planeProjectId, workItemID, assetID) payload := map[string]interface{}{"is_uploaded": true} resp, err := planeHttpPatch(path, payload) if err != nil { zap.L().Error("[Plane] ConfirmWorkItemAttachment 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) { zap.L().Error("[Plane] ConfirmWorkItemAttachment 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("ConfirmWorkItemAttachment 失败: %s", resp.Status) } return nil } // DeleteWorkItemAttachment 删除附件 func DeleteWorkItemAttachment(workItemID, assetID string) error { path := fmt.Sprintf("/api/v1/workspaces/%s/projects/%s/work-items/%s/attachments/%s/", planeWorkspaceSlug, planeProjectId, workItemID, assetID) resp, err := planeHttpDelete(path) if err != nil { zap.L().Error("[Plane] DeleteWorkItemAttachment 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 { zap.L().Error("[Plane] DeleteWorkItemAttachment 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("DeleteWorkItemAttachment 失败: %s", resp.Status) } return nil } // UploadWorkItemAttachment 完整附件上传流程(获取URL → 上传S3 → 确认) func UploadWorkItemAttachment(workItemID, fileName, mimeType string, fileData []byte) (*AssetUploadResult, error) { fileSize := int64(len(fileData)) // Step 1: 获取预签名 URL createResp, err := CreateWorkItemAttachment(workItemID, fileName, mimeType, fileSize) if err != nil { return nil, fmt.Errorf("获取上传凭据失败: %w", err) } // Step 2: 上传到 S3 if err := uploadToPresignedPost(&CreateAssetResponse{ UploadData: createResp.UploadData, AssetId: createResp.AssetId, AssetUrl: createResp.AssetUrl, }, fileData, mimeType); err != nil { return nil, fmt.Errorf("文件上传失败: %w", err) } // Step 3: 确认上传完成 if err := ConfirmWorkItemAttachment(workItemID, createResp.AssetId); err != nil { return nil, fmt.Errorf("确认上传失败: %w", err) } zap.L().Info("[Plane] WorkItem Attachment 上传完成", zap.String("assetId", createResp.AssetId), zap.String("fileName", fileName), ) return &AssetUploadResult{ AssetId: createResp.AssetId, AssetUrl: createResp.AssetUrl, }, nil }