| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- package plane_v1
- import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "strings"
- "time"
- "go.uber.org/zap"
- )
- // ==================== Asset 请求/响应结构 ====================
- // CreateAssetResponse Plane Asset创建响应(presigned POST 凭据)
- type CreateAssetResponse 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"`
- }
- // AssetUploadResult Asset上传最终结果(返回给前端)
- type AssetUploadResult struct {
- AssetId string `json:"assetId"`
- AssetUrl string `json:"assetUrl"`
- }
- // ==================== Asset API操作 ====================
- // UploadAsset 通过 Plane Assets API 上传文件(3步流程)
- // Step 1: 获取上传凭据 → Step 2: Presigned POST 上传文件 → Step 3: 确认上传完成
- func UploadAsset(fileName string, contentType string, fileSize int64, fileData []byte) (*AssetUploadResult, error) {
- // Step 1: 获取上传凭据
- assetInfo, err := createAsset(fileName, contentType, fileSize)
- if err != nil {
- return nil, err
- }
- zap.L().Debug("[Plane] Asset Create 成功",
- zap.String("assetId", assetInfo.AssetId),
- zap.String("uploadURL", assetInfo.UploadData.URL),
- )
- // Step 2: Presigned POST 上传文件
- if err := uploadToPresignedPost(assetInfo, fileData, contentType); err != nil {
- return nil, err
- }
- // Step 3: 确认上传完成 (PATCH)
- completeResult, err := completeAssetUpload(assetInfo.AssetId)
- if err != nil {
- return nil, err
- }
- // 解析最终 asset URL
- assetURL := ""
- if completeResult != nil && completeResult.AssetUrl != "" {
- assetURL = completeResult.AssetUrl
- }
- if assetURL == "" {
- assetURL = assetInfo.AssetUrl
- }
- if assetURL == "" {
- assetURL = fmt.Sprintf("%s/api/v1/workspaces/%s/assets/%s/", planeHost, planeWorkspaceSlug, assetInfo.AssetId)
- }
- zap.L().Info("[Plane] Asset 上传完成",
- zap.String("assetId", assetInfo.AssetId),
- zap.String("fileName", fileName),
- zap.String("assetUrl", assetURL),
- )
- return &AssetUploadResult{
- AssetId: assetInfo.AssetId,
- AssetUrl: assetURL,
- }, nil
- }
- // createAsset Step 1: 获取上传凭据
- func createAsset(fileName string, contentType string, fileSize int64) (*CreateAssetResponse, error) {
- url := fmt.Sprintf("/api/v1/workspaces/%s/assets/", planeWorkspaceSlug)
- body := map[string]interface{}{
- "name": fileName,
- "type": contentType,
- "size": fileSize,
- }
- resp, err := planeHttpPost(url, body)
- if err != nil {
- zap.L().Error("[Plane] Asset Create 请求失败", zap.String("err", err.Error()))
- return nil, fmt.Errorf("获取上传凭据失败: %w", err)
- }
- if !isSuccess(resp.StatusCode) {
- zap.L().Error("[Plane] Asset Create 返回错误",
- zap.String("status", resp.Status),
- zap.String("body", string(resp.Body)),
- )
- return nil, fmt.Errorf("获取上传凭据失败: %s", resp.Status)
- }
- var assetInfo CreateAssetResponse
- if err := json.Unmarshal(resp.Body, &assetInfo); err != nil {
- return nil, fmt.Errorf("解析上传凭据失败: %w (body: %s)", err, string(resp.Body))
- }
- return &assetInfo, nil
- }
- // uploadToPresignedPost Step 2: 使用 Presigned POST 上传文件到 S3/MinIO
- // Plane 返回的是 presigned POST(带 fields),需要 multipart/form-data 方式提交
- func uploadToPresignedPost(assetInfo *CreateAssetResponse, fileData []byte, contentType string) error {
- uploadURL := assetInfo.UploadData.URL
- if uploadURL == "" {
- return fmt.Errorf("upload URL 为空")
- }
- // Plane 容器内使用 host.docker.internal 访问宿主机,
- // 但后端服务运行在宿主机上,需要替换为 localhost
- uploadURL = strings.ReplaceAll(uploadURL, "host.docker.internal", "localhost")
- // 构造 multipart/form-data body
- var buf bytes.Buffer
- writer := multipart.NewWriter(&buf)
- // 先写入 Plane 返回的所有 fields(必须在 file 之前)
- for key, value := range assetInfo.UploadData.Fields {
- if err := writer.WriteField(key, value); err != nil {
- return fmt.Errorf("写入 field %s 失败: %w", key, err)
- }
- }
- // 最后写入文件(field name 必须为 "file")
- part, err := writer.CreateFormFile("file", "upload")
- if err != nil {
- return fmt.Errorf("创建 form file 失败: %w", err)
- }
- if _, err := part.Write(fileData); err != nil {
- return fmt.Errorf("写入文件数据失败: %w", err)
- }
- if err := writer.Close(); err != nil {
- return fmt.Errorf("关闭 multipart writer 失败: %w", err)
- }
- // 发送请求
- 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("[Plane] Asset Upload 请求失败", zap.String("err", err.Error()))
- return fmt.Errorf("文件上传失败: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- respBody, _ := io.ReadAll(resp.Body)
- zap.L().Error("[Plane] Asset Upload 返回错误",
- zap.String("status", resp.Status),
- zap.String("body", string(respBody)),
- )
- return fmt.Errorf("文件上传失败: %s", resp.Status)
- }
- zap.L().Debug("[Plane] Asset Upload 成功", zap.String("url", uploadURL))
- return nil
- }
- // completeAssetUpload Step 3: 确认上传完成
- func completeAssetUpload(assetId string) (*CreateAssetResponse, error) {
- url := fmt.Sprintf("/api/v1/workspaces/%s/assets/%s/", planeWorkspaceSlug, assetId)
- body := map[string]interface{}{
- "is_uploaded": true,
- }
- resp, err := planeHttpPatch(url, body)
- if err != nil {
- zap.L().Error("[Plane] Asset Complete 请求失败", zap.String("err", err.Error()))
- return nil, fmt.Errorf("确认上传失败: %w", err)
- }
- // 官方文档: 返回 204 No Content,无 body
- if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) {
- zap.L().Error("[Plane] Asset Complete 返回错误",
- zap.String("status", resp.Status),
- zap.String("body", string(resp.Body)),
- )
- return nil, fmt.Errorf("确认上传失败: %s", resp.Status)
- }
- return &CreateAssetResponse{}, nil
- }
- // DeleteAsset 删除 Plane Workspace Asset
- // DELETE /api/v1/workspaces/{workspace_slug}/assets/{asset_id}/
- func DeleteAsset(assetId string) error {
- url := fmt.Sprintf("/api/v1/workspaces/%s/assets/%s/", planeWorkspaceSlug, assetId)
- resp, err := planeHttpDelete(url)
- if err != nil {
- zap.L().Error("[Plane] Asset Delete 请求失败", zap.String("assetId", assetId), zap.String("err", err.Error()))
- return fmt.Errorf("删除附件失败: %w", err)
- }
- // 官方文档: 返回 204 No Content
- if resp.StatusCode != 204 && !isSuccess(resp.StatusCode) {
- zap.L().Error("[Plane] Asset Delete 返回错误",
- zap.String("assetId", assetId),
- zap.String("status", resp.Status),
- zap.String("body", string(resp.Body)),
- )
- return fmt.Errorf("删除附件失败: %s", resp.Status)
- }
- zap.L().Info("[Plane] Asset 删除成功", zap.String("assetId", assetId))
- return nil
- }
|