package httpapi import ( "bytes" "context" "crypto/rand" "encoding/base32" "encoding/base64" "encoding/binary" "fmt" "io" "net/http" "net/url" "os" "path" "strconv" "strings" "time" "git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/im_workbench/internal/managerconfig" "git.118im.com/im_team/im_workbench/internal/store" aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss" ) const aiTableExportDocxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" type aiExportedFile struct { FileID string FileName string DownloadURL string } type ossUploadConfig struct { Enabled bool Endpoint string Bucket string AccessKeyID string AccessKeySecret string ServeURL string CacheControl string } func (a *API) uploadAITableAnalysisExport(ctx context.Context, r *http.Request, st *store.Store, p *auth.Principal, item *store.AITableAnalysis) (*aiExportedFile, error) { cfg, err := a.ossUploadConfig(ctx) if err != nil { return nil, err } data, err := aiTableAnalysisDocx(item) if err != nil { return nil, err } fileID := newFileUploadID() publicID, storageID := formatFileUploadID(fileID) createdAt := time.Now().UTC() objectKey := path.Join("tenants", strconv.FormatInt(p.TenantID, 10), createdAt.Format("2006"), createdAt.Format("01"), storageID) client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) if err != nil { return nil, err } bucket, err := client.Bucket(cfg.Bucket) if err != nil { return nil, err } options := []aliyunoss.Option{aliyunoss.ContentType(aiTableExportDocxMIME)} if cfg.CacheControl != "" { options = append(options, aliyunoss.CacheControl(cfg.CacheControl)) } if err := bucket.PutObject(objectKey, bytes.NewReader(data), options...); err != nil { return nil, err } etag := "" if meta, err := bucket.GetObjectDetailedMeta(objectKey); err == nil { etag = strings.Trim(meta.Get("ETag"), `"`) } fileName := aiTableExportFileName(item) fileURLName := publicID + ".docx" if err := st.SaveCompletedFileUpload(ctx, store.CompletedFileUpload{ ID: fileID, UserID: p.UserID, MIMEType: aiTableExportDocxMIME, Size: int64(len(data)), ETag: etag, Location: objectKey, CreatedAt: createdAt, }); err != nil { return nil, err } if err := st.UpdateAITableAnalysisExportFileID(ctx, p.UserID, item.RecordID, fileURLName); err != nil { return nil, err } return &aiExportedFile{ FileID: fileURLName, FileName: fileName, DownloadURL: publicFileDownloadURL(r, cfg.ServeURL, fileURLName), }, nil } func downloadWorkbenchFileToTemp(r *http.Request, fileID, fileType string) (string, func(), error) { fileID = strings.TrimSpace(fileID) if fileID == "" { return "", func() {}, fmt.Errorf("empty file id") } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, absoluteURL(r, "/v0/file/s/"+url.PathEscape(fileID)), nil) if err != nil { return "", func() {}, err } copyHeader(req, r, "Authorization") copyHeader(req, r, "X-MeetingIM-APIKey") copyHeader(req, r, "X-Tenant-Code") copyHeader(req, r, "X-IM-Schema") copyHeader(req, r, "X-Request-ID") if auth := strings.TrimSpace(r.Header.Get("X-MeetingIM-Auth")); auth != "" { req.Header.Set("X-MeetingIM-Auth", auth) } else if token := bearerTokenFromHeader(r.Header.Get("Authorization")); token != "" { req.Header.Set("X-MeetingIM-Auth", "token "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", func() {}, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", func() {}, fmt.Errorf("file service returned %d", resp.StatusCode) } return responseBodyToTemp(resp.Body, fileType) } func (a *API) downloadOSSObjectToTemp(ctx context.Context, objectKey, fileType string) (string, func(), error) { objectKey = strings.Trim(strings.TrimSpace(objectKey), "/") if objectKey == "" { return "", func() {}, fmt.Errorf("empty object key") } cfg, err := a.ossUploadConfig(ctx) if err != nil { return "", func() {}, err } client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) if err != nil { return "", func() {}, err } bucket, err := client.Bucket(cfg.Bucket) if err != nil { return "", func() {}, err } reader, err := bucket.GetObject(objectKey) if err != nil { return "", func() {}, err } defer reader.Close() return responseBodyToTemp(reader, fileType) } func responseBodyToTemp(reader io.Reader, fileType string) (string, func(), error) { suffix := "." + strings.TrimLeft(strings.ToLower(strings.TrimSpace(fileType)), ".") if suffix == "." { suffix = ".table" } tmp, err := os.CreateTemp("", "im-workbench-ai-table-*"+suffix) if err != nil { return "", func() {}, err } tmpPath := tmp.Name() cleanup := func() { _ = os.Remove(tmpPath) } if _, err := io.Copy(tmp, reader); err != nil { _ = tmp.Close() cleanup() return "", func() {}, err } if err := tmp.Close(); err != nil { cleanup() return "", func() {}, err } return tmpPath, cleanup, nil } func copyHeader(dst *http.Request, src *http.Request, key string) { if value := strings.TrimSpace(src.Header.Get(key)); value != "" { dst.Header.Set(key, value) } } func bearerTokenFromHeader(value string) string { token, ok := strings.CutPrefix(strings.TrimSpace(value), "Bearer ") if !ok { return "" } return strings.TrimSpace(token) } func (a *API) ossUploadConfig(ctx context.Context) (ossUploadConfig, error) { values, err := managerconfig.New(a.manager).ThirdPartyConfig(ctx, "im_third_party", "oss_enabled", "oss_endpoint", "oss_bucket", "oss_access_key_id", "oss_access_key_secret", "oss_serve_url", "oss_cache_control", ) if err != nil { return ossUploadConfig{}, err } cfg := ossUploadConfig{ Enabled: parseConfigBool(values["oss_enabled"]), Endpoint: strings.TrimSpace(values["oss_endpoint"]), Bucket: strings.TrimSpace(values["oss_bucket"]), AccessKeyID: strings.TrimSpace(values["oss_access_key_id"]), AccessKeySecret: strings.TrimSpace(values["oss_access_key_secret"]), ServeURL: strings.TrimSpace(values["oss_serve_url"]), CacheControl: strings.TrimSpace(values["oss_cache_control"]), } if cfg.ServeURL == "" { cfg.ServeURL = "/v0/file/s/" } if !cfg.Enabled || cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" { return ossUploadConfig{}, fmt.Errorf("oss config incomplete") } return cfg, nil } func parseConfigBool(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on", "enabled": return true default: return false } } func newFileUploadID() int64 { var random [8]byte if _, err := rand.Read(random[:]); err != nil { return time.Now().UnixNano() } suffix := int64(binary.LittleEndian.Uint64(random[:]) & ((1 << 20) - 1)) return (time.Now().UnixMilli() << 20) | suffix } func formatFileUploadID(id int64) (publicID string, storageID string) { encrypted := encryptUID(id) publicID = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encrypted) storageID = strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(encrypted)) return publicID, storageID } func publicFileDownloadURL(r *http.Request, serveURL, fileName string) string { serveURL = strings.TrimSpace(serveURL) if strings.HasPrefix(serveURL, "http://") || strings.HasPrefix(serveURL, "https://") { return strings.TrimRight(serveURL, "/") + "/" + fileName } if serveURL == "" { serveURL = "/v0/file/s/" } if !strings.HasPrefix(serveURL, "/") { serveURL = "/" + serveURL } return absoluteURL(r, strings.TrimRight(serveURL, "/")+"/"+fileName) }