plane_asset.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. package plane_v2
  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 v2 API ====================
  14. // Entity Type 常量
  15. const (
  16. EntityIssueDescription = "ISSUE_DESCRIPTION"
  17. EntityIssueAttachment = "ISSUE_ATTACHMENT"
  18. EntityCommentDescription = "COMMENT_DESCRIPTION"
  19. EntityPageDescription = "PAGE_DESCRIPTION"
  20. EntityDraftIssueDescription = "DRAFT_ISSUE_DESCRIPTION"
  21. EntityProjectCover = "PROJECT_COVER"
  22. EntityUserAvatar = "USER_AVATAR"
  23. EntityUserCover = "USER_COVER"
  24. EntityWorkspaceLogo = "WORKSPACE_LOGO"
  25. )
  26. // WebAssetUploadResult 资源上传结果
  27. type WebAssetUploadResult struct {
  28. UploadData struct {
  29. URL string `json:"url"`
  30. Fields map[string]string `json:"fields"`
  31. } `json:"upload_data"`
  32. AssetID string `json:"asset_id"`
  33. AssetURL string `json:"asset_url"`
  34. }
  35. // CreateProjectAsset 创建项目资源(获取预签名 URL)
  36. func CreateProjectAsset(slug, projectID, fileName, mimeType string, size int64, entityType string) (*WebAssetUploadResult, error) {
  37. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/", slug, projectID)
  38. payload := map[string]interface{}{
  39. "name": fileName,
  40. "type": mimeType,
  41. "size": size,
  42. "entity_type": entityType,
  43. "entity_identifier": "",
  44. }
  45. resp, err := planeWebPost(url, payload)
  46. if err != nil {
  47. zap.L().Error("[PlaneWeb] CreateProjectAsset 请求失败", zap.Error(err))
  48. return nil, err
  49. }
  50. if !isWebSuccess(resp.StatusCode) {
  51. zap.L().Error("[PlaneWeb] CreateProjectAsset 返回错误",
  52. zap.String("status", resp.Status),
  53. zap.String("body", string(resp.Body)),
  54. )
  55. return nil, fmt.Errorf("CreateProjectAsset 失败: %s", resp.Status)
  56. }
  57. var result WebAssetUploadResult
  58. if err := json.Unmarshal(resp.Body, &result); err != nil {
  59. return nil, fmt.Errorf("解析资源上传响应失败: %w", err)
  60. }
  61. return &result, nil
  62. }
  63. // UploadToS3 上传文件到 S3(使用预签名 POST URL)
  64. func UploadToS3(uploadURL string, fields map[string]string, fileData []byte, fileName string) error {
  65. // 替换容器内地址为宿主机地址
  66. uploadURL = strings.ReplaceAll(uploadURL, "host.docker.internal", "localhost")
  67. var buf bytes.Buffer
  68. writer := multipart.NewWriter(&buf)
  69. // 先写入所有 fields(必须在 file 之前)
  70. for k, v := range fields {
  71. if err := writer.WriteField(k, v); err != nil {
  72. return fmt.Errorf("写入 field %s 失败: %w", k, err)
  73. }
  74. }
  75. // 写入文件
  76. part, err := writer.CreateFormFile("file", fileName)
  77. if err != nil {
  78. return fmt.Errorf("创建 form file 失败: %w", err)
  79. }
  80. if _, err := part.Write(fileData); err != nil {
  81. return fmt.Errorf("写入文件数据失败: %w", err)
  82. }
  83. writer.Close()
  84. req, err := http.NewRequest(http.MethodPost, uploadURL, &buf)
  85. if err != nil {
  86. return fmt.Errorf("创建上传请求失败: %w", err)
  87. }
  88. req.Header.Set("Content-Type", writer.FormDataContentType())
  89. client := &http.Client{Timeout: 60 * time.Second}
  90. resp, err := client.Do(req)
  91. if err != nil {
  92. zap.L().Error("[PlaneWeb] S3 Upload 请求失败", zap.Error(err))
  93. return fmt.Errorf("文件上传失败: %w", err)
  94. }
  95. defer resp.Body.Close()
  96. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  97. respBody, _ := io.ReadAll(resp.Body)
  98. zap.L().Error("[PlaneWeb] S3 Upload 返回错误",
  99. zap.String("status", resp.Status),
  100. zap.String("body", string(respBody)),
  101. )
  102. return fmt.Errorf("文件上传失败: %s", resp.Status)
  103. }
  104. zap.L().Debug("[PlaneWeb] S3 Upload 成功")
  105. return nil
  106. }
  107. // ConfirmProjectAssetUpload 确认项目资源上传完成
  108. func ConfirmProjectAssetUpload(slug, projectID, assetID string) error {
  109. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID)
  110. // Plane 期望空对象 {} 作为请求体(不能传 nil,否则序列化为 null 导致 500)
  111. resp, err := planeWebPatch(url, map[string]interface{}{})
  112. if err != nil {
  113. zap.L().Error("[PlaneWeb] ConfirmProjectAssetUpload 请求失败", zap.Error(err))
  114. return err
  115. }
  116. if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) {
  117. zap.L().Error("[PlaneWeb] ConfirmProjectAssetUpload 返回错误", zap.String("status", resp.Status))
  118. return fmt.Errorf("ConfirmProjectAssetUpload 失败: %s", resp.Status)
  119. }
  120. return nil
  121. }
  122. // GetProjectAsset 下载项目资源(返回302重定向URL)
  123. func GetProjectAsset(slug, projectID, assetID string) ([]byte, error) {
  124. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID)
  125. resp, err := planeWebGet(url)
  126. if err != nil {
  127. zap.L().Error("[PlaneWeb] GetProjectAsset 请求失败", zap.Error(err))
  128. return nil, err
  129. }
  130. return resp.Body, nil
  131. }
  132. // GetProjectAssetURL 获取项目资源的预签名访问URL(从302 Location中提取)
  133. func GetProjectAssetURL(slug, projectID, assetID string) (string, error) {
  134. reqURL := webBaseURL + fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID)
  135. req, err := http.NewRequest(http.MethodGet, reqURL, nil)
  136. if err != nil {
  137. return "", fmt.Errorf("创建请求失败: %w", err)
  138. }
  139. req.Header.Set("Accept", "application/json")
  140. req.Header.Set("Referer", webBaseURL)
  141. resp, err := webClient.Do(req)
  142. if err != nil {
  143. return "", fmt.Errorf("请求失败: %w", err)
  144. }
  145. defer resp.Body.Close()
  146. if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
  147. location := resp.Header.Get("Location")
  148. if location != "" {
  149. return location, nil
  150. }
  151. }
  152. if isWebSuccess(resp.StatusCode) {
  153. return reqURL, nil
  154. }
  155. return "", fmt.Errorf("获取资源URL失败: status=%d", resp.StatusCode)
  156. }
  157. // DeleteProjectAsset 删除项目资源
  158. func DeleteProjectAsset(slug, projectID, assetID string) error {
  159. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/", slug, projectID, assetID)
  160. resp, err := planeWebDelete(url)
  161. if err != nil {
  162. zap.L().Error("[PlaneWeb] DeleteProjectAsset 请求失败", zap.Error(err))
  163. return err
  164. }
  165. if resp.StatusCode != 204 {
  166. zap.L().Error("[PlaneWeb] DeleteProjectAsset 返回错误", zap.String("status", resp.Status))
  167. return fmt.Errorf("DeleteProjectAsset 失败: %s", resp.Status)
  168. }
  169. return nil
  170. }
  171. // BindAssetsToEntity 批量绑定资源到实体
  172. func BindAssetsToEntity(slug, projectID, entityID string, assetIDs []string) error {
  173. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/projects/%s/%s/bulk/", slug, projectID, entityID)
  174. payload := map[string]interface{}{
  175. "asset_ids": assetIDs,
  176. }
  177. resp, err := planeWebPost(url, payload)
  178. if err != nil {
  179. zap.L().Error("[PlaneWeb] BindAssetsToEntity 请求失败", zap.Error(err))
  180. return err
  181. }
  182. if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) {
  183. zap.L().Error("[PlaneWeb] BindAssetsToEntity 返回错误", zap.String("status", resp.Status))
  184. return fmt.Errorf("BindAssetsToEntity 失败: %s", resp.Status)
  185. }
  186. return nil
  187. }
  188. // CreateWorkspaceAsset 创建工作空间资源
  189. func CreateWorkspaceAsset(slug, fileName, mimeType string, size int64, entityType string) (*WebAssetUploadResult, error) {
  190. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/", slug)
  191. payload := map[string]interface{}{
  192. "name": fileName,
  193. "type": mimeType,
  194. "size": size,
  195. "entity_type": entityType,
  196. "entity_identifier": "",
  197. }
  198. resp, err := planeWebPost(url, payload)
  199. if err != nil {
  200. zap.L().Error("[PlaneWeb] CreateWorkspaceAsset 请求失败", zap.Error(err))
  201. return nil, err
  202. }
  203. if !isWebSuccess(resp.StatusCode) {
  204. zap.L().Error("[PlaneWeb] CreateWorkspaceAsset 返回错误",
  205. zap.String("status", resp.Status),
  206. zap.String("body", string(resp.Body)),
  207. )
  208. return nil, fmt.Errorf("CreateWorkspaceAsset 失败: %s", resp.Status)
  209. }
  210. var result WebAssetUploadResult
  211. if err := json.Unmarshal(resp.Body, &result); err != nil {
  212. return nil, fmt.Errorf("解析资源上传响应失败: %w", err)
  213. }
  214. return &result, nil
  215. }
  216. // ConfirmWorkspaceAssetUpload 确认工作空间资源上传完成
  217. func ConfirmWorkspaceAssetUpload(slug, assetID string) error {
  218. url := fmt.Sprintf("/api/assets/v2/workspaces/%s/%s/", slug, assetID)
  219. resp, err := planeWebPatch(url, map[string]interface{}{})
  220. if err != nil {
  221. zap.L().Error("[PlaneWeb] ConfirmWorkspaceAssetUpload 请求失败", zap.Error(err))
  222. return err
  223. }
  224. if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) {
  225. zap.L().Error("[PlaneWeb] ConfirmWorkspaceAssetUpload 返回错误", zap.String("status", resp.Status))
  226. return fmt.Errorf("ConfirmWorkspaceAssetUpload 失败: %s", resp.Status)
  227. }
  228. return nil
  229. }
  230. // CreateUserAsset 创建用户资源
  231. func CreateUserAsset(fileName, mimeType string, size int64, entityType string) (*WebAssetUploadResult, error) {
  232. payload := map[string]interface{}{
  233. "name": fileName,
  234. "type": mimeType,
  235. "size": size,
  236. "entity_type": entityType,
  237. "entity_identifier": "",
  238. }
  239. resp, err := planeWebPost("/api/assets/v2/user-assets/", payload)
  240. if err != nil {
  241. zap.L().Error("[PlaneWeb] CreateUserAsset 请求失败", zap.Error(err))
  242. return nil, err
  243. }
  244. if !isWebSuccess(resp.StatusCode) {
  245. zap.L().Error("[PlaneWeb] CreateUserAsset 返回错误",
  246. zap.String("status", resp.Status),
  247. zap.String("body", string(resp.Body)),
  248. )
  249. return nil, fmt.Errorf("CreateUserAsset 失败: %s", resp.Status)
  250. }
  251. var result WebAssetUploadResult
  252. if err := json.Unmarshal(resp.Body, &result); err != nil {
  253. return nil, fmt.Errorf("解析资源上传响应失败: %w", err)
  254. }
  255. return &result, nil
  256. }
  257. // ConfirmUserAssetUpload 确认用户资源上传完成
  258. func ConfirmUserAssetUpload(assetID string) error {
  259. url := fmt.Sprintf("/api/assets/v2/user-assets/%s/", assetID)
  260. resp, err := planeWebPatch(url, map[string]interface{}{})
  261. if err != nil {
  262. zap.L().Error("[PlaneWeb] ConfirmUserAssetUpload 请求失败", zap.Error(err))
  263. return err
  264. }
  265. if resp.StatusCode != 204 && !isWebSuccess(resp.StatusCode) {
  266. zap.L().Error("[PlaneWeb] ConfirmUserAssetUpload 返回错误", zap.String("status", resp.Status))
  267. return fmt.Errorf("ConfirmUserAssetUpload 失败: %s", resp.Status)
  268. }
  269. return nil
  270. }
  271. // UploadProjectAsset 完整上传项目资源流程(组合方法)
  272. // 1. 创建资源获取预签名URL → 2. 上传到S3 → 3. 确认上传完成
  273. func UploadProjectAsset(slug, projectID, fileName, mimeType string, fileData []byte, entityType string) (*WebAssetUploadResult, error) {
  274. fileSize := int64(len(fileData))
  275. // Step 1: 创建资源
  276. result, err := CreateProjectAsset(slug, projectID, fileName, mimeType, fileSize, entityType)
  277. if err != nil {
  278. return nil, fmt.Errorf("创建资源失败: %w", err)
  279. }
  280. zap.L().Debug("[PlaneWeb] Asset Create 成功",
  281. zap.String("assetId", result.AssetID),
  282. zap.String("uploadURL", result.UploadData.URL),
  283. )
  284. // Step 2: 上传到 S3
  285. if err := UploadToS3(result.UploadData.URL, result.UploadData.Fields, fileData, fileName); err != nil {
  286. return nil, fmt.Errorf("上传文件到S3失败: %w", err)
  287. }
  288. // Step 3: 确认上传完成
  289. if err := ConfirmProjectAssetUpload(slug, projectID, result.AssetID); err != nil {
  290. return nil, fmt.Errorf("确认上传失败: %w", err)
  291. }
  292. zap.L().Info("[PlaneWeb] Asset 上传完成",
  293. zap.String("assetId", result.AssetID),
  294. zap.String("fileName", fileName),
  295. zap.String("assetUrl", result.AssetURL),
  296. )
  297. return result, nil
  298. }
  299. // UploadCurrentProjectAsset 在当前项目上传资源
  300. func UploadCurrentProjectAsset(fileName, mimeType string, fileData []byte, entityType string) (*WebAssetUploadResult, error) {
  301. return UploadProjectAsset(webWorkspaceSlug, webProjectId, fileName, mimeType, fileData, entityType)
  302. }