package plane_v2 import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "strings" "time" "go.uber.org/zap" ) // ==================== Asset v2 API ==================== // Entity Type 常量 const ( EntityIssueDescription = "ISSUE_DESCRIPTION" EntityIssueAttachment = "ISSUE_ATTACHMENT" EntityCommentDescription = "COMMENT_DESCRIPTION" EntityPageDescription = "PAGE_DESCRIPTION" EntityDraftIssueDescription = "DRAFT_ISSUE_DESCRIPTION" EntityProjectCover = "PROJECT_COVER" EntityUserAvatar = "USER_AVATAR" EntityUserCover = "USER_COVER" EntityWorkspaceLogo = "WORKSPACE_LOGO" ) // WebAssetUploadResult 资源上传结果 type WebAssetUploadResult 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"` } // CreateProjectAsset 创建项目资源(获取预签名 URL) func CreateProjectAsset(slug, projectID, fileName, mimeType string, size int64, entityType string) (*WebAssetUploadResult, error) { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/", slug, projectID) payload := map[string]interface{}{ "name": fileName, "type": mimeType, "size": size, "entity_type": entityType, "entity_identifier": "", } resp, err := planeWebPost(url, payload) if err != nil { zap.L().Error("[PlaneWeb] CreateProjectAsset 请求失败", zap.Error(err)) return nil, err } if !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] CreateProjectAsset 返回错误", zap.String("status", resp.Status), zap.String("body", string(resp.Body)), ) return nil, fmt.Errorf("CreateProjectAsset 失败: %s", resp.Status) } var result WebAssetUploadResult if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("解析资源上传响应失败: %w", err) } return &result, nil } // UploadToS3 上传文件到 S3(使用预签名 POST URL) func UploadToS3(uploadURL string, fields map[string]string, fileData []byte, fileName string) error { // 替换容器内地址为宿主机地址 uploadURL = strings.ReplaceAll(uploadURL, "host.docker.internal", "localhost") var buf bytes.Buffer writer := multipart.NewWriter(&buf) // 先写入所有 fields(必须在 file 之前) for k, v := range fields { if err := writer.WriteField(k, v); err != nil { return fmt.Errorf("写入 field %s 失败: %w", k, err) } } // 写入文件 part, err := writer.CreateFormFile("file", fileName) if err != nil { return fmt.Errorf("创建 form file 失败: %w", err) } if _, err := part.Write(fileData); err != nil { return fmt.Errorf("写入文件数据失败: %w", err) } writer.Close() 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("[PlaneWeb] S3 Upload 请求失败", zap.Error(err)) return fmt.Errorf("文件上传失败: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) zap.L().Error("[PlaneWeb] S3 Upload 返回错误", zap.String("status", resp.Status), zap.String("body", string(respBody)), ) return fmt.Errorf("文件上传失败: %s", resp.Status) } zap.L().Debug("[PlaneWeb] S3 Upload 成功") return nil } // ConfirmProjectAssetUpload 确认项目资源上传完成 func ConfirmProjectAssetUpload(slug, projectID, assetID string) error { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID) // Plane 期望空对象 {} 作为请求体(不能传 nil,否则序列化为 null 导致 500) resp, err := planeWebPatch(url, map[string]interface{}{}) if err != nil { zap.L().Error("[PlaneWeb] ConfirmProjectAssetUpload 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] ConfirmProjectAssetUpload 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("ConfirmProjectAssetUpload 失败: %s", resp.Status) } return nil } // GetProjectAsset 下载项目资源(返回302重定向URL) func GetProjectAsset(slug, projectID, assetID string) ([]byte, error) { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID) resp, err := planeWebGet(url) if err != nil { zap.L().Error("[PlaneWeb] GetProjectAsset 请求失败", zap.Error(err)) return nil, err } return resp.Body, nil } // GetProjectAssetURL 获取项目资源的预签名访问URL(从302 Location中提取) func GetProjectAssetURL(slug, projectID, assetID string) (string, error) { reqURL := webBaseURL + fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID) req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { return "", fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", webBaseURL) resp, err := webClient.Do(req) if err != nil { return "", fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently { location := resp.Header.Get("Location") if location != "" { return location, nil } } if isWebSuccess(resp.StatusCode) { return reqURL, nil } return "", fmt.Errorf("获取资源URL失败: status=%d", resp.StatusCode) } // DeleteProjectAsset 删除项目资源 func DeleteProjectAsset(slug, projectID, assetID string) error { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID) resp, err := planeWebDelete(url) if err != nil { zap.L().Error("[PlaneWeb] DeleteProjectAsset 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 { zap.L().Error("[PlaneWeb] DeleteProjectAsset 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("DeleteProjectAsset 失败: %s", resp.Status) } return nil } // BindAssetsToEntity 批量绑定资源到实体 func BindAssetsToEntity(slug, projectID, entityID string, assetIDs []string) error { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/bulk/", slug, projectID, entityID) payload := map[string]interface{}{ "asset_ids": assetIDs, } resp, err := planeWebPost(url, payload) if err != nil { zap.L().Error("[PlaneWeb] BindAssetsToEntity 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] BindAssetsToEntity 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("BindAssetsToEntity 失败: %s", resp.Status) } return nil } // CreateWorkspaceAsset 创建工作空间资源 func CreateWorkspaceAsset(slug, fileName, mimeType string, size int64, entityType string) (*WebAssetUploadResult, error) { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/", slug) payload := map[string]interface{}{ "name": fileName, "type": mimeType, "size": size, "entity_type": entityType, "entity_identifier": "", } resp, err := planeWebPost(url, payload) if err != nil { zap.L().Error("[PlaneWeb] CreateWorkspaceAsset 请求失败", zap.Error(err)) return nil, err } if !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] CreateWorkspaceAsset 返回错误", zap.String("status", resp.Status), zap.String("body", string(resp.Body)), ) return nil, fmt.Errorf("CreateWorkspaceAsset 失败: %s", resp.Status) } var result WebAssetUploadResult if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("解析资源上传响应失败: %w", err) } return &result, nil } // ConfirmWorkspaceAssetUpload 确认工作空间资源上传完成 func ConfirmWorkspaceAssetUpload(slug, assetID string) error { url := fmt.Sprintf("/api/assets/v2/workspaces/%s/%s/", slug, assetID) resp, err := planeWebPatch(url, map[string]interface{}{}) if err != nil { zap.L().Error("[PlaneWeb] ConfirmWorkspaceAssetUpload 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] ConfirmWorkspaceAssetUpload 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("ConfirmWorkspaceAssetUpload 失败: %s", resp.Status) } return nil } // CreateUserAsset 创建用户资源 func CreateUserAsset(fileName, mimeType string, size int64, entityType string) (*WebAssetUploadResult, error) { payload := map[string]interface{}{ "name": fileName, "type": mimeType, "size": size, "entity_type": entityType, "entity_identifier": "", } resp, err := planeWebPost("/api/assets/v2/user-assets/", payload) if err != nil { zap.L().Error("[PlaneWeb] CreateUserAsset 请求失败", zap.Error(err)) return nil, err } if !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] CreateUserAsset 返回错误", zap.String("status", resp.Status), zap.String("body", string(resp.Body)), ) return nil, fmt.Errorf("CreateUserAsset 失败: %s", resp.Status) } var result WebAssetUploadResult if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("解析资源上传响应失败: %w", err) } return &result, nil } // ConfirmUserAssetUpload 确认用户资源上传完成 func ConfirmUserAssetUpload(assetID string) error { url := fmt.Sprintf("/api/assets/v2/user-assets/%s/", assetID) resp, err := planeWebPatch(url, map[string]interface{}{}) if err != nil { zap.L().Error("[PlaneWeb] ConfirmUserAssetUpload 请求失败", zap.Error(err)) return err } if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) { zap.L().Error("[PlaneWeb] ConfirmUserAssetUpload 返回错误", zap.String("status", resp.Status)) return fmt.Errorf("ConfirmUserAssetUpload 失败: %s", resp.Status) } return nil } // UploadProjectAsset 完整上传项目资源流程(组合方法) // 1. 创建资源获取预签名URL → 2. 上传到S3 → 3. 确认上传完成 func UploadProjectAsset(slug, projectID, fileName, mimeType string, fileData []byte, entityType string) (*WebAssetUploadResult, error) { fileSize := int64(len(fileData)) // Step 1: 创建资源 result, err := CreateProjectAsset(slug, projectID, fileName, mimeType, fileSize, entityType) if err != nil { return nil, fmt.Errorf("创建资源失败: %w", err) } zap.L().Debug("[PlaneWeb] Asset Create 成功", zap.String("assetId", result.AssetID), zap.String("uploadURL", result.UploadData.URL), ) // Step 2: 上传到 S3 if err := UploadToS3(result.UploadData.URL, result.UploadData.Fields, fileData, fileName); err != nil { return nil, fmt.Errorf("上传文件到S3失败: %w", err) } // Step 3: 确认上传完成 if err := ConfirmProjectAssetUpload(slug, projectID, result.AssetID); err != nil { return nil, fmt.Errorf("确认上传失败: %w", err) } zap.L().Info("[PlaneWeb] Asset 上传完成", zap.String("assetId", result.AssetID), zap.String("fileName", fileName), zap.String("assetUrl", result.AssetURL), ) return result, nil } // UploadCurrentProjectAsset 在当前项目上传资源 func UploadCurrentProjectAsset(fileName, mimeType string, fileData []byte, entityType string) (*WebAssetUploadResult, error) { return UploadProjectAsset(webWorkspaceSlug, webProjectId, fileName, mimeType, fileData, entityType) }