From 044337ac036454fdff18fec3712947444d9b8736 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Tue, 2 Jun 2026 11:14:42 +0800 Subject: [PATCH] Return signed OSS URLs for site images --- .env.example | 1 + config.yaml | 1 + config/config.go | 3 ++ services/aftersales_service.go | 28 +++++++++---- services/oss_service.go | 72 +++++++++++++++++++++++++++++++--- 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 70db402..62bcc37 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,4 @@ APP_OSS_PREFIX=aftersales-confirmations APP_OSS_PUBLIC_BASE_URL= APP_OSS_MAX_FILE_SIZE_MB=5 APP_OSS_MAX_FILES=6 +APP_OSS_SIGNED_URL_EXPIRE=3600 diff --git a/config.yaml b/config.yaml index 3949a4c..876a0d3 100644 --- a/config.yaml +++ b/config.yaml @@ -32,3 +32,4 @@ oss: public_base_url: "" max_file_size_mb: 5 max_files: 6 + signed_url_expire: 3600 diff --git a/config/config.go b/config/config.go index b173edb..5a37d9b 100644 --- a/config/config.go +++ b/config/config.go @@ -54,6 +54,7 @@ type OSSConfig struct { PublicBaseURL string `mapstructure:"public_base_url"` MaxFileSizeMB int `mapstructure:"max_file_size_mb"` MaxFiles int `mapstructure:"max_files"` + SignedURLExpire int `mapstructure:"signed_url_expire"` } // AppConfig 应用程序配置 @@ -153,6 +154,7 @@ func setDefaults() { viper.SetDefault("oss.public_base_url", "") viper.SetDefault("oss.max_file_size_mb", 5) viper.SetDefault("oss.max_files", 6) + viper.SetDefault("oss.signed_url_expire", 3600) } // bindEnvVariables 绑定环境变量 @@ -189,6 +191,7 @@ func bindEnvVariables() { viper.BindEnv("oss.public_base_url") viper.BindEnv("oss.max_file_size_mb") viper.BindEnv("oss.max_files") + viper.BindEnv("oss.signed_url_expire") } // validateConfig 验证配置 diff --git a/services/aftersales_service.go b/services/aftersales_service.go index 18014f7..5678f37 100644 --- a/services/aftersales_service.go +++ b/services/aftersales_service.go @@ -146,7 +146,19 @@ func hydrateAftersalesOrder(order *models.AftersalesOrder) { if order == nil { return } - order.SiteImages = parseSiteImages(order.SiteImagesJSON) + order.SiteImages = siteImageAccessURLs(parseSiteImages(order.SiteImagesJSON)) +} + +func siteImageAccessURLs(images []string) []string { + if len(images) == 0 { + return nil + } + ossService := NewOSSService() + urls := make([]string, 0, len(images)) + for _, image := range images { + urls = append(urls, ossService.AccessURL(image)) + } + return urls } // generateUniqueSerial 生成唯一的售后工单序列号 @@ -385,7 +397,7 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales ConfirmedAt: order.ConfirmedAt, Signature: order.Signature, ResponsibleSignature: order.ResponsibleSignature, - SiteImages: parseSiteImages(order.SiteImagesJSON), + SiteImages: siteImageAccessURLs(parseSiteImages(order.SiteImagesJSON)), } if order.Technician != nil { view.TechnicianName = order.Technician.Name @@ -393,7 +405,7 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales return view, nil } -// UploadSiteImages 上传客户确认现场图片到 OSS 并保存 URL。 +// UploadSiteImages 上传客户确认现场图片到 OSS 并保存 object key。 func (s *AftersalesService) UploadSiteImages(serialNumber string, files []*multipart.FileHeader) ([]string, error) { normalized := normalizeAftersalesSerial(serialNumber) if len(files) == 0 { @@ -425,7 +437,7 @@ func (s *AftersalesService) UploadSiteImages(serialNumber string, files []*multi } ossService := NewOSSService() - uploaded := make([]string, 0, len(files)) + uploadedKeys := make([]string, 0, len(files)) for _, fh := range files { if fh.Size <= 0 { return nil, fmt.Errorf("图片 %s 内容为空", fh.Filename) @@ -445,21 +457,21 @@ func (s *AftersalesService) UploadSiteImages(serialNumber string, files []*multi } objectKey := buildSiteImageObjectKey(cfg.Prefix, normalized, fh.Filename) - url, err := ossService.UploadObject(objectKey, file, contentType) + err = ossService.UploadObject(objectKey, file, contentType) _ = file.Close() if err != nil { return nil, err } - uploaded = append(uploaded, url) + uploadedKeys = append(uploadedKeys, objectKey) } - merged := append(existing, uploaded...) + merged := append(existing, uploadedKeys...) order.SiteImagesJSON = encodeSiteImages(merged) if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("保存现场图片失败: %w", err) } - return merged, nil + return siteImageAccessURLs(merged), nil } func buildSiteImageObjectKey(prefix string, serialNumber string, filename string) string { diff --git a/services/oss_service.go b/services/oss_service.go index 2f8eebd..1d42298 100644 --- a/services/oss_service.go +++ b/services/oss_service.go @@ -20,19 +20,19 @@ func NewOSSService() OSSService { return OSSService{cfg: config.GetAppConfig().OSS} } -func (s OSSService) UploadObject(objectKey string, reader io.Reader, contentType string) (string, error) { +func (s OSSService) UploadObject(objectKey string, reader io.Reader, contentType string) error { if err := s.validateConfig(); err != nil { - return "", err + return err } client, err := oss.New(normalizeOSSEndpoint(s.cfg.Endpoint), s.cfg.AccessKeyID, s.cfg.AccessKeySecret) if err != nil { - return "", fmt.Errorf("初始化 OSS 客户端失败: %w", err) + return fmt.Errorf("初始化 OSS 客户端失败: %w", err) } bucket, err := client.Bucket(s.cfg.Bucket) if err != nil { - return "", fmt.Errorf("打开 OSS Bucket 失败: %w", err) + return fmt.Errorf("打开 OSS Bucket 失败: %w", err) } options := []oss.Option{} @@ -40,10 +40,10 @@ func (s OSSService) UploadObject(objectKey string, reader io.Reader, contentType options = append(options, oss.ContentType(contentType)) } if err := bucket.PutObject(objectKey, reader, options...); err != nil { - return "", fmt.Errorf("上传现场图片失败: %w", err) + return fmt.Errorf("上传现场图片失败: %w", err) } - return s.publicURL(objectKey), nil + return nil } func (s OSSService) validateConfig() error { @@ -68,6 +68,66 @@ func (s OSSService) publicURL(objectKey string) string { return fmt.Sprintf("%s://%s.%s/%s", u.Scheme, s.cfg.Bucket, u.Host, objectKey) } +func (s OSSService) AccessURL(objectRef string) string { + objectKey := s.objectKeyFromRef(objectRef) + if objectKey == "" { + return objectRef + } + if strings.TrimSpace(s.cfg.PublicBaseURL) != "" { + return s.publicURL(objectKey) + } + url, err := s.SignedURL(objectKey) + if err != nil { + return s.publicURL(objectKey) + } + return url +} + +func (s OSSService) SignedURL(objectKey string) (string, error) { + if err := s.validateConfig(); err != nil { + return "", err + } + client, err := oss.New(normalizeOSSEndpoint(s.cfg.Endpoint), s.cfg.AccessKeyID, s.cfg.AccessKeySecret) + if err != nil { + return "", fmt.Errorf("初始化 OSS 客户端失败: %w", err) + } + bucket, err := client.Bucket(s.cfg.Bucket) + if err != nil { + return "", fmt.Errorf("打开 OSS Bucket 失败: %w", err) + } + expires := s.cfg.SignedURLExpire + if expires <= 0 { + expires = 3600 + } + return bucket.SignURL(objectKey, oss.HTTPGet, int64(expires)) +} + +func (s OSSService) objectKeyFromRef(objectRef string) string { + objectRef = strings.TrimSpace(objectRef) + if objectRef == "" { + return "" + } + if !strings.HasPrefix(objectRef, "http://") && !strings.HasPrefix(objectRef, "https://") { + return strings.TrimLeft(objectRef, "/") + } + u, err := url.Parse(objectRef) + if err != nil { + return "" + } + host := strings.ToLower(u.Host) + bucketHostPrefix := strings.ToLower(s.cfg.Bucket) + "." + if strings.HasPrefix(host, bucketHostPrefix) { + return strings.TrimLeft(u.Path, "/") + } + if base := strings.TrimSpace(s.cfg.PublicBaseURL); base != "" { + baseURL, err := url.Parse(base) + if err == nil && strings.EqualFold(baseURL.Host, u.Host) { + return strings.TrimLeft(strings.TrimPrefix(u.Path, baseURL.Path), "/") + } + } + return "" +} + func normalizeOSSEndpoint(endpoint string) string { endpoint = strings.TrimSpace(endpoint) if endpoint == "" {