plane_asset.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. package plane_v1
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "mime/multipart"
  8. "net/http"
  9. "strings"
  10. "time"
  11. "go.uber.org/zap"
  12. )
  13. // ==================== Asset 请求/响应结构 ====================
  14. // CreateAssetResponse Plane Asset创建响应(presigned POST 凭据)
  15. type CreateAssetResponse struct {
  16. UploadData struct {
  17. URL string `json:"url"`
  18. Fields map[string]string `json:"fields"`
  19. } `json:"upload_data"`
  20. AssetId string `json:"asset_id"`
  21. AssetUrl string `json:"asset_url"`
  22. }
  23. // AssetUploadResult Asset上传最终结果(返回给前端)
  24. type AssetUploadResult struct {
  25. AssetId string `json:"assetId"`
  26. AssetUrl string `json:"assetUrl"`
  27. }
  28. // ==================== Asset API操作 ====================
  29. // UploadAsset 通过 Plane Assets API 上传文件(3步流程)
  30. // Step 1: 获取上传凭据 → Step 2: Presigned POST 上传文件 → Step 3: 确认上传完成
  31. func UploadAsset(fileName string, contentType string, fileSize int64, fileData []byte) (*AssetUploadResult, error) {
  32. // Step 1: 获取上传凭据
  33. assetInfo, err := createAsset(fileName, contentType, fileSize)
  34. if err != nil {
  35. return nil, err
  36. }
  37. zap.L().Debug("[Plane] Asset Create 成功",
  38. zap.String("assetId", assetInfo.AssetId),
  39. zap.String("uploadURL", assetInfo.UploadData.URL),
  40. )
  41. // Step 2: Presigned POST 上传文件
  42. if err := uploadToPresignedPost(assetInfo, fileData, contentType); err != nil {
  43. return nil, err
  44. }
  45. // Step 3: 确认上传完成 (PATCH)
  46. completeResult, err := completeAssetUpload(assetInfo.AssetId)
  47. if err != nil {
  48. return nil, err
  49. }
  50. // 解析最终 asset URL
  51. assetURL := ""
  52. if completeResult != nil && completeResult.AssetUrl != "" {
  53. assetURL = completeResult.AssetUrl
  54. }
  55. if assetURL == "" {
  56. assetURL = assetInfo.AssetUrl
  57. }
  58. if assetURL == "" {
  59. assetURL = fmt.Sprintf("%s/api/v1/workspaces/%s/assets/%s/", planeHost, planeWorkspaceSlug, assetInfo.AssetId)
  60. }
  61. zap.L().Info("[Plane] Asset 上传完成",
  62. zap.String("assetId", assetInfo.AssetId),
  63. zap.String("fileName", fileName),
  64. zap.String("assetUrl", assetURL),
  65. )
  66. return &AssetUploadResult{
  67. AssetId: assetInfo.AssetId,
  68. AssetUrl: assetURL,
  69. }, nil
  70. }
  71. // createAsset Step 1: 获取上传凭据
  72. func createAsset(fileName string, contentType string, fileSize int64) (*CreateAssetResponse, error) {
  73. url := fmt.Sprintf("/api/v1/workspaces/%s/assets/", planeWorkspaceSlug)
  74. body := map[string]interface{}{
  75. "name": fileName,
  76. "type": contentType,
  77. "size": fileSize,
  78. }
  79. resp, err := planeHttpPost(url, body)
  80. if err != nil {
  81. zap.L().Error("[Plane] Asset Create 请求失败", zap.String("err", err.Error()))
  82. return nil, fmt.Errorf("获取上传凭据失败: %w", err)
  83. }
  84. if !isSuccess(resp.StatusCode) {
  85. zap.L().Error("[Plane] Asset Create 返回错误",
  86. zap.String("status", resp.Status),
  87. zap.String("body", string(resp.Body)),
  88. )
  89. return nil, fmt.Errorf("获取上传凭据失败: %s", resp.Status)
  90. }
  91. var assetInfo CreateAssetResponse
  92. if err := json.Unmarshal(resp.Body, &assetInfo); err != nil {
  93. return nil, fmt.Errorf("解析上传凭据失败: %w (body: %s)", err, string(resp.Body))
  94. }
  95. return &assetInfo, nil
  96. }
  97. // uploadToPresignedPost Step 2: 使用 Presigned POST 上传文件到 S3/MinIO
  98. // Plane 返回的是 presigned POST(带 fields),需要 multipart/form-data 方式提交
  99. func uploadToPresignedPost(assetInfo *CreateAssetResponse, fileData []byte, contentType string) error {
  100. uploadURL := assetInfo.UploadData.URL
  101. if uploadURL == "" {
  102. return fmt.Errorf("upload URL 为空")
  103. }
  104. // Plane 容器内使用 host.docker.internal 访问宿主机,
  105. // 但后端服务运行在宿主机上,需要替换为 localhost
  106. uploadURL = strings.ReplaceAll(uploadURL, "host.docker.internal", "localhost")
  107. // 构造 multipart/form-data body
  108. var buf bytes.Buffer
  109. writer := multipart.NewWriter(&buf)
  110. // 先写入 Plane 返回的所有 fields(必须在 file 之前)
  111. for key, value := range assetInfo.UploadData.Fields {
  112. if err := writer.WriteField(key, value); err != nil {
  113. return fmt.Errorf("写入 field %s 失败: %w", key, err)
  114. }
  115. }
  116. // 最后写入文件(field name 必须为 "file")
  117. part, err := writer.CreateFormFile("file", "upload")
  118. if err != nil {
  119. return fmt.Errorf("创建 form file 失败: %w", err)
  120. }
  121. if _, err := part.Write(fileData); err != nil {
  122. return fmt.Errorf("写入文件数据失败: %w", err)
  123. }
  124. if err := writer.Close(); err != nil {
  125. return fmt.Errorf("关闭 multipart writer 失败: %w", err)
  126. }
  127. // 发送请求
  128. req, err := http.NewRequest(http.MethodPost, uploadURL, &buf)
  129. if err != nil {
  130. return fmt.Errorf("创建上传请求失败: %w", err)
  131. }
  132. req.Header.Set("Content-Type", writer.FormDataContentType())
  133. client := &http.Client{Timeout: 60 * time.Second}
  134. resp, err := client.Do(req)
  135. if err != nil {
  136. zap.L().Error("[Plane] Asset Upload 请求失败", zap.String("err", err.Error()))
  137. return fmt.Errorf("文件上传失败: %w", err)
  138. }
  139. defer resp.Body.Close()
  140. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  141. respBody, _ := io.ReadAll(resp.Body)
  142. zap.L().Error("[Plane] Asset Upload 返回错误",
  143. zap.String("status", resp.Status),
  144. zap.String("body", string(respBody)),
  145. )
  146. return fmt.Errorf("文件上传失败: %s", resp.Status)
  147. }
  148. zap.L().Debug("[Plane] Asset Upload 成功", zap.String("url", uploadURL))
  149. return nil
  150. }
  151. // completeAssetUpload Step 3: 确认上传完成
  152. func completeAssetUpload(assetId string) (*CreateAssetResponse, error) {
  153. url := fmt.Sprintf("/api/v1/workspaces/%s/assets/%s/", planeWorkspaceSlug, assetId)
  154. body := map[string]interface{}{
  155. "is_uploaded": true,
  156. }
  157. resp, err := planeHttpPatch(url, body)
  158. if err != nil {
  159. zap.L().Error("[Plane] Asset Complete 请求失败", zap.String("err", err.Error()))
  160. return nil, fmt.Errorf("确认上传失败: %w", err)
  161. }
  162. // 官方文档: 返回 204 No Content,无 body
  163. if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) {
  164. zap.L().Error("[Plane] Asset Complete 返回错误",
  165. zap.String("status", resp.Status),
  166. zap.String("body", string(resp.Body)),
  167. )
  168. return nil, fmt.Errorf("确认上传失败: %s", resp.Status)
  169. }
  170. return &CreateAssetResponse{}, nil
  171. }
  172. // DeleteAsset 删除 Plane Workspace Asset
  173. // DELETE /api/v1/workspaces/{workspace_slug}/assets/{asset_id}/
  174. func DeleteAsset(assetId string) error {
  175. url := fmt.Sprintf("/api/v1/workspaces/%s/assets/%s/", planeWorkspaceSlug, assetId)
  176. resp, err := planeHttpDelete(url)
  177. if err != nil {
  178. zap.L().Error("[Plane] Asset Delete 请求失败", zap.String("assetId", assetId), zap.String("err", err.Error()))
  179. return fmt.Errorf("删除附件失败: %w", err)
  180. }
  181. // 官方文档: 返回 204 No Content
  182. if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) {
  183. zap.L().Error("[Plane] Asset Delete 返回错误",
  184. zap.String("assetId", assetId),
  185. zap.String("status", resp.Status),
  186. zap.String("body", string(resp.Body)),
  187. )
  188. return fmt.Errorf("删除附件失败: %s", resp.Status)
  189. }
  190. zap.L().Info("[Plane] Asset 删除成功", zap.String("assetId", assetId))
  191. return nil
  192. }