| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- 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)
- }
|