/* * Copyright 2025 coze-dev Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package upload import ( "bytes" "context" "encoding/xml" "errors" "fmt" "hash/crc32" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "math" "mime" "mime/multipart" "path" "regexp" "sort" "strconv" "strings" "time" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" "github.com/google/uuid" "github.com/coze-dev/coze-studio/backend/api/model/file/upload" "github.com/coze-dev/coze-studio/backend/api/model/flow/dataengine/dataset" "github.com/coze-dev/coze-studio/backend/api/model/ocean/cloud/developer_api" "github.com/coze-dev/coze-studio/backend/api/model/ocean/cloud/playground" "github.com/coze-dev/coze-studio/backend/application/base/ctxutil" "github.com/coze-dev/coze-studio/backend/domain/upload/entity" "github.com/coze-dev/coze-studio/backend/infra/contract/cache" "github.com/coze-dev/coze-studio/backend/infra/contract/storage" "github.com/coze-dev/coze-studio/backend/pkg/errorx" "github.com/coze-dev/coze-studio/backend/pkg/lang/conv" "github.com/coze-dev/coze-studio/backend/pkg/lang/ptr" "github.com/coze-dev/coze-studio/backend/pkg/logs" "github.com/coze-dev/coze-studio/backend/pkg/sonic" "github.com/coze-dev/coze-studio/backend/types/consts" "github.com/coze-dev/coze-studio/backend/types/errno" ) func InitService(oss storage.Storage, cache cache.Cmdable) { SVC.cache = cache SVC.oss = oss } var SVC = &UploadService{} type UploadService struct { oss storage.Storage cache cache.Cmdable } const ( uploadKey = "UploadServiceUpload:%s" uploadPartKey = "UploadServiceUpload:%s/parts" partKey = "UploadServiceUpload/%s/part-%s" ) func (u *UploadService) PartUploadFileInit(ctx context.Context, objKey string) (uploadID string, err error) { uploadID = uuid.NewString() key := fmt.Sprintf(uploadKey, uploadID) err = u.cache.HSet(ctx, key, "objkey", objKey, ).Err() if err != nil { return "", err } err = u.cache.Expire(ctx, key, time.Minute*10).Err() return } type PartUploadFileRequest struct { UploadID string PartNumber string Data []byte } type PartUploadFileResponse struct { Crc32 string } type PartUploadFileCompleteRequest struct { UploadID string ObjKey string Crc32Map map[string]string } func (u *UploadService) PartUploadFile(ctx context.Context, req *PartUploadFileRequest) (resp *PartUploadFileResponse, err error) { key := fmt.Sprintf(uploadKey, req.UploadID) exists, err := u.cache.Exists(ctx, key).Result() if err != nil || exists == 0 { return nil, fmt.Errorf("upload session invalid: %v", err) } crc32Val := crc32.ChecksumIEEE(req.Data) partTosKey := fmt.Sprintf(partKey, req.UploadID, req.PartNumber) err = u.oss.PutObject(ctx, partTosKey, req.Data, storage.WithExpires(time.Now().Add(10*time.Minute))) if err != nil { return nil, err } partMeta := map[string]interface{}{ "tos_key": partTosKey, } partMetaData, err := sonic.Marshal(partMeta) if err != nil { return nil, err } partKey := fmt.Sprintf(uploadPartKey, req.UploadID) err = u.cache.HSet(ctx, partKey, req.PartNumber, string(partMetaData)).Err() if err != nil { return nil, err } err = u.cache.Expire(ctx, partKey, time.Minute*10).Err() if err != nil { return nil, err } return &PartUploadFileResponse{ Crc32: fmt.Sprintf("%08x", crc32Val), }, nil } type tosPart struct { PartNum int Data []byte } func getContentType(uri string) (contentType string) { _ = mime.AddExtensionType(".svg", "image/svg+xml") _ = mime.AddExtensionType(".svgz", "image/svg+xml") _ = mime.AddExtensionType(".webp", "image/webp") _ = mime.AddExtensionType(".ico", "image/x-icon") fileExtension := path.Base(uri) ext := path.Ext(fileExtension) contentType = mime.TypeByExtension(ext) return } func (u *UploadService) PartUploadFileComplete(ctx context.Context, req *PartUploadFileCompleteRequest) error { partKey := fmt.Sprintf(uploadPartKey, req.UploadID) parts, err := u.cache.HGetAll(ctx, partKey).Result() if err != nil { return err } tosParts := []*tosPart{} for partNumStr, partData := range parts { var partMeta map[string]string if err := sonic.Unmarshal([]byte(partData), &partMeta); err != nil { return fmt.Errorf("failed to parse part metadata: %v", err) } partNum, err := strconv.ParseInt(partNumStr, 10, 64) if err != nil { return err } objKey, exist := partMeta["tos_key"] if !exist { return errors.New("tos key not exist") } byteData, err := u.oss.GetObject(ctx, objKey) if err != nil { return err } tosParts = append(tosParts, &tosPart{PartNum: int(partNum), Data: byteData}) } if len(tosParts) == 0 { return errors.New("tos part is null") } sort.Slice(tosParts, func(i, j int) bool { return tosParts[i].PartNum < tosParts[j].PartNum }) if tosParts[len(tosParts)-1].PartNum != len(tosParts) || len(tosParts) != len(req.Crc32Map) { return errors.New("check parts fail") } totalData := []byte{} for _, val := range tosParts { crc32 := fmt.Sprintf("%08x", crc32.ChecksumIEEE(val.Data)) crc32Check := req.Crc32Map[strconv.Itoa(val.PartNum)] if crc32 != crc32Check { return errors.New("crc32 check fail") } totalData = append(totalData, val.Data...) } contentType := getContentType(req.ObjKey) if len(contentType) != 0 { err = u.oss.PutObject(ctx, req.ObjKey, totalData, storage.WithContentType(contentType)) } else { err = u.oss.PutObject(ctx, req.ObjKey, totalData) } return err } func (u *UploadService) GetIcon(ctx context.Context, req *developer_api.GetIconRequest) ( resp *developer_api.GetIconResponse, err error, ) { iconURI := map[developer_api.IconType]string{ developer_api.IconType_Bot: consts.DefaultAgentIcon, developer_api.IconType_User: consts.DefaultUserIcon, developer_api.IconType_Plugin: consts.DefaultPluginIcon, developer_api.IconType_Dataset: consts.DefaultDatasetIcon, developer_api.IconType_Workflow: consts.DefaultWorkflowIcon, developer_api.IconType_Imageflow: consts.DefaultPluginIcon, developer_api.IconType_Society: consts.DefaultPluginIcon, developer_api.IconType_Connector: consts.DefaultPluginIcon, developer_api.IconType_ChatFlow: consts.DefaultPluginIcon, developer_api.IconType_Voice: consts.DefaultPluginIcon, developer_api.IconType_Enterprise: consts.DefaultTeamIcon, } uri := iconURI[req.GetIconType()] if uri == "" { return nil, errorx.New(errno.ErrUploadInvalidType, errorx.KV("type", conv.Int64ToStr(int64(req.GetIconType())))) } url, err := u.oss.GetObjectUrl(ctx, iconURI[req.GetIconType()]) if err != nil { return nil, err } return &developer_api.GetIconResponse{ Data: &developer_api.GetIconResponseData{ IconList: []*developer_api.Icon{ { URL: url, URI: uri, }, }, }, }, nil } func stringToMap(input string) map[string]string { result := make(map[string]string) pairs := strings.Split(input, ",") for _, pair := range pairs { parts := strings.Split(pair, ":") if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) result[key] = value } } return result } func (u *UploadService) UploadFileCommon(ctx context.Context, req *upload.CommonUploadRequest, fullPath string) (*upload.CommonUploadResponse, error) { resp := upload.NewCommonUploadResponse() re := regexp.MustCompile(consts.UploadURI + `/([^?]+)`) match := re.FindStringSubmatch(fullPath) if len(match) == 0 { return nil, errorx.New(errno.ErrUploadInvalidParamCode, errorx.KV("msg", "tos key not found")) } objKey := match[1] if strings.Contains(fullPath, "?uploads") { uploadID, err := u.PartUploadFileInit(ctx, objKey) if err != nil { return resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } resp.Error = &upload.Error{Code: 200} resp.Payload = &upload.Payload{UploadID: uploadID} return resp, nil } if len(ptr.From(req.PartNumber)) != 0 { _, err := u.PartUploadFile(ctx, &PartUploadFileRequest{ UploadID: ptr.From(req.UploadID), PartNumber: ptr.From(req.PartNumber), Data: req.ByteData, }) if err != nil { return resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } resp.Error = &upload.Error{Code: 200} return resp, nil } if len(ptr.From(req.UploadID)) != 0 { mp := stringToMap(string(req.ByteData)) err := u.PartUploadFileComplete(ctx, &PartUploadFileCompleteRequest{ UploadID: ptr.From(req.UploadID), ObjKey: objKey, Crc32Map: mp, }) if err != nil { return resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } resp.Error = &upload.Error{Code: 200} resp.Payload = &upload.Payload{Key: uuid.NewString()} return resp, nil } var err error contentType := getContentType(objKey) if len(contentType) != 0 { err = u.oss.PutObject(ctx, objKey, req.ByteData, storage.WithContentType(contentType)) } else { err = u.oss.PutObject(ctx, objKey, req.ByteData) } if err != nil { return resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } resp.Error = &upload.Error{Code: 200} resp.Payload = &upload.Payload{Key: uuid.NewString()} return resp, err } func (u *UploadService) UploadFile(ctx context.Context, data []byte, objKey string) (*developer_api.UploadFileResponse, error) { err := u.oss.PutObject(ctx, objKey, data) if err != nil { return nil, err } url, err := u.oss.GetObjectUrl(ctx, objKey) if err != nil { return nil, err } return &developer_api.UploadFileResponse{ Data: &developer_api.UploadFileData{ UploadURL: url, UploadURI: objKey, }, }, nil } func (u *UploadService) GetShortcutIcons(ctx context.Context) ([]*playground.FileInfo, error) { shortcutIcons := entity.GetDefaultShortcutIconURI() fileList := make([]*playground.FileInfo, 0, len(shortcutIcons)) for _, uri := range shortcutIcons { url, err := u.oss.GetObjectUrl(ctx, uri) if err == nil { fileList = append(fileList, &playground.FileInfo{ URL: url, URI: uri, }) } } return fileList, nil } func parseMultipartFormData(ctx context.Context, req *playground.UploadFileOpenRequest) (*multipart.Form, error) { _, params, err := mime.ParseMediaType(req.ContentType) if err != nil { return nil, errorx.New(errno.ErrUploadInvalidContentTypeCode, errorx.KV("content-type", req.ContentType)) } br := bytes.NewReader(req.Data) mr := multipart.NewReader(br, params["boundary"]) form, err := mr.ReadForm(maxFileSize) if errors.Is(err, multipart.ErrMessageTooLarge) { return nil, errorx.New(errno.ErrUploadInvalidFileSizeCode) } else if err != nil { return nil, errorx.New(errno.ErrUploadMultipartFormDataReadFailedCode) } return form, nil } func genObjName(name string, id string) string { return fmt.Sprintf("%s/%s/%s", "bot_files", id, name, ) } func (u *UploadService) UploadFileOpen(ctx context.Context, req *playground.UploadFileOpenRequest) (*playground.UploadFileOpenResponse, error) { resp := playground.UploadFileOpenResponse{} resp.File = new(playground.File) uid := ctxutil.MustGetUIDFromApiAuthCtx(ctx) if uid == 0 { return nil, errorx.New(errno.ErrKnowledgePermissionCode, errorx.KV("msg", "session required")) } form, err := parseMultipartFormData(ctx, req) if err != nil { logs.CtxErrorf(ctx, "parse multipart form data failed, err: %v", err) return nil, err } if len(form.File["file"]) == 0 { return nil, errorx.New(errno.ErrUploadEmptyFileCode) } else if len(form.File["file"]) > 1 { return nil, errorx.New(errno.ErrUploadFileUploadGreaterOneCode) } fileHeader := form.File["file"][0] // open file file, err := fileHeader.Open() if err != nil { return nil, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", "fileHeader open failed")) } defer file.Close() data, err := io.ReadAll(file) if err != nil { return nil, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", "file upload io read failed")) } resp.File.Bytes = int64(len(data)) randID := uuid.NewString() objName := genObjName(fileHeader.Filename, randID) resp.File.FileName = fileHeader.Filename resp.File.URI = objName err = u.oss.PutObject(ctx, objName, data) if err != nil { return nil, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", "file upload to oss failed")) } url, err := u.oss.GetObjectUrl(ctx, objName) if err != nil { return nil, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", "get object url failed")) } resp.File.CreatedAt = time.Now().Unix() resp.File.URL = url return &resp, nil } func (u *UploadService) GetIconForDataset(ctx context.Context, req *dataset.GetIconRequest) (*dataset.GetIconResponse, error) { resp := dataset.NewGetIconResponse() var uri string switch req.FormatType { case dataset.FormatType_Text: uri = TextKnowledgeDefaultIcon case dataset.FormatType_Table: uri = TableKnowledgeDefaultIcon case dataset.FormatType_Image: uri = ImageKnowledgeDefaultIcon case dataset.FormatType_Database: uri = DatabaseDefaultIcon default: uri = TextKnowledgeDefaultIcon } iconUrl, err := u.oss.GetObjectUrl(ctx, uri) if err != nil { return resp, err } resp.Icon = &dataset.Icon{ URL: iconUrl, URI: uri, } return resp, nil } func (u *UploadService) UploadSessionKey(ctx context.Context, sessionKey string, tosKey string) error { return u.cache.Set(ctx, sessionKey, tosKey, time.Minute*30).Err() } type GetObjInfoBySessionKey struct { ObjKey string Width int32 Height int32 } func isImageUri(uri string) bool { if uri == "" { return false } uri = strings.ToLower(uri) fileExtension := path.Base(uri) ext := path.Ext(fileExtension) ext = ext[1:] imageExtensions := map[string]bool{ "jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true, "webp": true, "tiff": true, "svg": true, "ico": true, } // 检查扩展名是否在图片扩展名列表中 return imageExtensions[ext] } func (u *UploadService) GetObjInfoBySessionKey(ctx context.Context, sessionKey string) (*GetObjInfoBySessionKey, error) { resp := GetObjInfoBySessionKey{} objKey, err := u.cache.Get(ctx, sessionKey).Result() if err != nil { return nil, err } resp.ObjKey = objKey if isImageUri(objKey) { content, err := u.oss.GetObject(ctx, objKey) if err != nil { return nil, err } if isSVG(objKey) { width, height, err := getSVGDimensions(content) if err != nil { logs.CtxErrorf(ctx, "get svg dimensions failed, err: %v", err) // default val resp.Width = 100 resp.Height = 100 return &resp, nil } resp.Width = width resp.Height = height } else { img, _, err := image.Decode(bytes.NewReader(content)) if err != nil { logs.CtxErrorf(ctx, "decode image failed, err: %v", err) // default val resp.Width = 100 resp.Height = 100 return &resp, nil } resp.Width = int32(img.Bounds().Dx()) resp.Height = int32(img.Bounds().Dy()) } } return &resp, nil } type SVG struct { Width string `xml:"width,attr"` Height string `xml:"height,attr"` ViewBox string `xml:"viewBox,attr"` } // 获取 SVG 尺寸 func getSVGDimensions(content []byte) (width, height int32, err error) { decoder := xml.NewDecoder(bytes.NewReader(content)) var svg SVG if err := decoder.Decode(&svg); err != nil { return 100, 100, nil } // 尝试从width属性获取 if svg.Width != "" { w, err := parseDimension(svg.Width) if err == nil { width = w } } // 尝试从height属性获取 if svg.Height != "" { h, err := parseDimension(svg.Height) if err == nil { height = h } } // 如果width或height未设置,尝试从viewBox获取 if width == 0 || height == 0 { if svg.ViewBox != "" { parts := strings.Fields(svg.ViewBox) if len(parts) >= 4 { if width == 0 { w, err := strconv.ParseInt(parts[2], 10, 32) if err == nil { width = int32(w) } } if height == 0 { h, err := strconv.ParseInt(parts[3], 10, 32) if err == nil { height = int32(h) } } } } } if width == 0 || height == 0 { return 100, 100, nil } return width, height, nil } func parseDimension(dim string) (int32, error) { // 去除单位(px, pt, em, %等)和空格 dim = strings.TrimSpace(dim) dim = strings.TrimRightFunc(dim, func(r rune) bool { return (r < '0' || r > '9') && r != '.' && r != '-' && r != '+' }) // 解析为float64 value, err := strconv.ParseFloat(dim, 64) if err != nil { return 0, err } // 四舍五入转换为int32 if value > math.MaxInt32 { return math.MaxInt32, nil } if value < math.MinInt32 { return math.MinInt32, nil } return int32(math.Round(value)), nil } func isSVG(uri string) bool { uri = strings.ToLower(uri) fileExtension := path.Base(uri) ext := path.Ext(fileExtension) ext = ext[1:] return ext == "svg" } func (u *UploadService) ApplyImageUpload(ctx context.Context, req *upload.ApplyUploadActionRequest, host string) (*upload.ApplyUploadActionResponse, error) { resp := upload.ApplyUploadActionResponse{} storeUri := "tos-cn-i-v4nquku3lp/" + uuid.NewString() + ptr.From(req.FileExtension) sessionKey := uuid.NewString() auth := uuid.NewString() uploadID := uuid.NewString() uploadHost := string(host) + consts.UploadURI resp.ResponseMetadata = &upload.ResponseMetadata{ RequestId: uuid.NewString(), Action: "ApplyImageUpload", Version: "", Service: "", Region: "", } resp.Result = &upload.ApplyUploadActionResult{ UploadAddress: &upload.UploadAddress{ StoreInfos: []*upload.StoreInfo{ { StoreUri: storeUri, Auth: auth, UploadID: uploadID, }, }, UploadHosts: []string{uploadHost}, SessionKey: sessionKey, }, InnerUploadAddress: &upload.InnerUploadAddress{ UploadNodes: []*upload.UploadNode{ { StoreInfos: []*upload.StoreInfo{ { StoreUri: storeUri, Auth: auth, UploadID: uploadID, }, }, UploadHost: uploadHost, SessionKey: sessionKey, }, }, }, RequestId: ptr.Of(uuid.NewString()), } err := u.UploadSessionKey(ctx, sessionKey, storeUri) if err != nil { return &resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } return &resp, nil } func (u *UploadService) CommitImageUpload(ctx context.Context, req *upload.ApplyUploadActionRequest, host string) (*upload.ApplyUploadActionResponse, error) { resp := upload.ApplyUploadActionResponse{} type ssKey struct { SessionKey string `json:"SessionKey"` } sskey := ssKey{} err := sonic.Unmarshal(req.ByteData, &sskey) if err != nil { return &resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } objInfo, err := u.GetObjInfoBySessionKey(ctx, sskey.SessionKey) if err != nil { return &resp, errorx.New(errno.ErrUploadSystemErrorCode, errorx.KV("msg", err.Error())) } resp.ResponseMetadata = &upload.ResponseMetadata{ RequestId: uuid.NewString(), Action: "ApplyImageUpload", Version: "", Service: "", Region: "", } resp.Result = &upload.ApplyUploadActionResult{ Results: []*upload.UploadResult{ { Uri: objInfo.ObjKey, UriStatus: 2000, }, }, RequestId: ptr.Of(uuid.NewString()), PluginResult: []*upload.PluginResult{ { FileName: objInfo.ObjKey, SourceUri: objInfo.ObjKey, ImageUri: objInfo.ObjKey, ImageWidth: objInfo.Width, ImageHeight: objInfo.Height, }, }, } return &resp, nil }