package plane_v1 import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "strings" "time" "go.uber.org/zap" ) // ==================== Asset 请求/响应结构 ==================== // CreateAssetResponse Plane Asset创建响应(presigned POST 凭据) type CreateAssetResponse 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"` } // AssetUploadResult Asset上传最终结果(返回给前端) type AssetUploadResult struct { AssetId string `json:"assetId"` AssetUrl string `json:"assetUrl"` } // ==================== Asset API操作 ==================== // UploadAsset 通过 Plane Assets API 上传文件(3步流程) // Step 1: 获取上传凭据 → Step 2: Presigned POST 上传文件 → Step 3: 确认上传完成 func UploadAsset(fileName string, contentType string, fileSize int64, fileData []byte) (*AssetUploadResult, error) { // Step 1: 获取上传凭据 assetInfo, err := createAsset(fileName, contentType, fileSize) if err != nil { return nil, err } zap.L().Debug("[Plane] Asset Create 成功", zap.String("assetId", assetInfo.AssetId), zap.String("uploadURL", assetInfo.UploadData.URL), ) // Step 2: Presigned POST 上传文件 if err := uploadToPresignedPost(assetInfo, fileData, contentType); err != nil { return nil, err } // Step 3: 确认上传完成 (PATCH) completeResult, err := completeAssetUpload(assetInfo.AssetId) if err != nil { return nil, err } // 解析最终 asset URL assetURL := "" if completeResult != nil && completeResult.AssetUrl != "" { assetURL = completeResult.AssetUrl } if assetURL == "" { assetURL = assetInfo.AssetUrl } if assetURL == "" { assetURL = fmt.Sprintf("%s/api/v1/workspaces/%s/assets/%s/", planeHost, planeWorkspaceSlug, assetInfo.AssetId) } zap.L().Info("[Plane] Asset 上传完成", zap.String("assetId", assetInfo.AssetId), zap.String("fileName", fileName), zap.String("assetUrl", assetURL), ) return &AssetUploadResult{ AssetId: assetInfo.AssetId, AssetUrl: assetURL, }, nil } // createAsset Step 1: 获取上传凭据 func createAsset(fileName string, contentType string, fileSize int64) (*CreateAssetResponse, error) { url := fmt.Sprintf("/api/v1/workspaces/%s/assets/", planeWorkspaceSlug) body := map[string]interface{}{ "name": fileName, "type": contentType, "size": fileSize, } resp, err := planeHttpPost(url, body) if err != nil { zap.L().Error("[Plane] Asset Create 请求失败", zap.String("err", err.Error())) return nil, fmt.Errorf("获取上传凭据失败: %w", err) } if !isSuccess(resp.StatusCode) { zap.L().Error("[Plane] Asset Create 返回错误", zap.String("status", resp.Status), zap.String("body", string(resp.Body)), ) return nil, fmt.Errorf("获取上传凭据失败: %s", resp.Status) } var assetInfo CreateAssetResponse if err := json.Unmarshal(resp.Body, &assetInfo); err != nil { return nil, fmt.Errorf("解析上传凭据失败: %w (body: %s)", err, string(resp.Body)) } return &assetInfo, nil } // uploadToPresignedPost Step 2: 使用 Presigned POST 上传文件到 S3/MinIO // Plane 返回的是 presigned POST(带 fields),需要 multipart/form-data 方式提交 func uploadToPresignedPost(assetInfo *CreateAssetResponse, fileData []byte, contentType string) error { uploadURL := assetInfo.UploadData.URL if uploadURL == "" { return fmt.Errorf("upload URL 为空") } // Plane 容器内使用 host.docker.internal 访问宿主机, // 但后端服务运行在宿主机上,需要替换为 localhost uploadURL = strings.ReplaceAll(uploadURL, "host.docker.internal", "localhost") // 构造 multipart/form-data body var buf bytes.Buffer writer := multipart.NewWriter(&buf) // 先写入 Plane 返回的所有 fields(必须在 file 之前) for key, value := range assetInfo.UploadData.Fields { if err := writer.WriteField(key, value); err != nil { return fmt.Errorf("写入 field %s 失败: %w", key, err) } } // 最后写入文件(field name 必须为 "file") part, err := writer.CreateFormFile("file", "upload") if err != nil { return fmt.Errorf("创建 form file 失败: %w", err) } if _, err := part.Write(fileData); err != nil { return fmt.Errorf("写入文件数据失败: %w", err) } if err := writer.Close(); err != nil { return fmt.Errorf("关闭 multipart writer 失败: %w", err) } // 发送请求 req, err := http.NewRequest(http.MethodPost, uploadURL, &buf) if err != nil { return fmt.Errorf("创建上传请求失败: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(req) if err != nil { zap.L().Error("[Plane] Asset Upload 请求失败", zap.String("err", err.Error())) return fmt.Errorf("文件上传失败: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) zap.L().Error("[Plane] Asset Upload 返回错误", zap.String("status", resp.Status), zap.String("body", string(respBody)), ) return fmt.Errorf("文件上传失败: %s", resp.Status) } zap.L().Debug("[Plane] Asset Upload 成功", zap.String("url", uploadURL)) return nil } // completeAssetUpload Step 3: 确认上传完成 func completeAssetUpload(assetId string) (*CreateAssetResponse, error) { url := fmt.Sprintf("/api/v1/workspaces/%s/assets/%s/", planeWorkspaceSlug, assetId) body := map[string]interface{}{ "is_uploaded": true, } resp, err := planeHttpPatch(url, body) if err != nil { zap.L().Error("[Plane] Asset Complete 请求失败", zap.String("err", err.Error())) return nil, fmt.Errorf("确认上传失败: %w", err) } // 官方文档: 返回 204 No Content,无 body if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) { zap.L().Error("[Plane] Asset Complete 返回错误", zap.String("status", resp.Status), zap.String("body", string(resp.Body)), ) return nil, fmt.Errorf("确认上传失败: %s", resp.Status) } return &CreateAssetResponse{}, nil } // DeleteAsset 删除 Plane Workspace Asset // DELETE /api/v1/workspaces/{workspace_slug}/assets/{asset_id}/ func DeleteAsset(assetId string) error { url := fmt.Sprintf("/api/v1/workspaces/%s/assets/%s/", planeWorkspaceSlug, assetId) resp, err := planeHttpDelete(url) if err != nil { zap.L().Error("[Plane] Asset Delete 请求失败", zap.String("assetId", assetId), zap.String("err", err.Error())) return fmt.Errorf("删除附件失败: %w", err) } // 官方文档: 返回 204 No Content if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) { zap.L().Error("[Plane] Asset Delete 返回错误", zap.String("assetId", assetId), zap.String("status", resp.Status), zap.String("body", string(resp.Body)), ) return fmt.Errorf("删除附件失败: %s", resp.Status) } zap.L().Info("[Plane] Asset 删除成功", zap.String("assetId", assetId)) return nil }