|
@@ -1,367 +0,0 @@
|
|
|
-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)
|
|
|
|
|
-}
|
|
|