diff --git a/.env.example b/.env.example index 3e78975..70db402 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,15 @@ APP_DATABASE_POSTGRES_SSLMODE=disable # JWT Configuration APP_JWT_SECRET=your-secret-key-here-change-in-production -APP_JWT_EXPIRE=7200 \ No newline at end of file +APP_JWT_EXPIRE=7200 + +# Aliyun OSS Configuration +APP_OSS_REGION=oss-cn-hangzhou +APP_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +APP_OSS_BUCKET=trace-system +APP_OSS_ACCESS_KEY_ID= +APP_OSS_ACCESS_KEY_SECRET= +APP_OSS_PREFIX=aftersales-confirmations +APP_OSS_PUBLIC_BASE_URL= +APP_OSS_MAX_FILE_SIZE_MB=5 +APP_OSS_MAX_FILES=6 diff --git a/config.yaml b/config.yaml index b5a2af4..3949a4c 100644 --- a/config.yaml +++ b/config.yaml @@ -20,3 +20,15 @@ database: jwt: secret: "your-secret-key-here-change-in-production" expire: 7200 # 过期时间(秒) + +# 阿里云 OSS 配置 +oss: + region: "oss-cn-hangzhou" + endpoint: "oss-cn-hangzhou.aliyuncs.com" + bucket: "trace-system" + access_key_id: "" + access_key_secret: "" + prefix: "aftersales-confirmations" + public_base_url: "" + max_file_size_mb: 5 + max_files: 6 diff --git a/config/config.go b/config/config.go index afa03c3..b173edb 100644 --- a/config/config.go +++ b/config/config.go @@ -43,11 +43,25 @@ type JWTConfig struct { Expire int `mapstructure:"expire"` } +// OSSConfig 阿里云 OSS 配置 +type OSSConfig struct { + Region string `mapstructure:"region"` + Endpoint string `mapstructure:"endpoint"` + Bucket string `mapstructure:"bucket"` + AccessKeyID string `mapstructure:"access_key_id"` + AccessKeySecret string `mapstructure:"access_key_secret"` + Prefix string `mapstructure:"prefix"` + PublicBaseURL string `mapstructure:"public_base_url"` + MaxFileSizeMB int `mapstructure:"max_file_size_mb"` + MaxFiles int `mapstructure:"max_files"` +} + // AppConfig 应用程序配置 type AppConfig struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` JWT JWTConfig `mapstructure:"jwt"` + OSS OSSConfig `mapstructure:"oss"` } // 全局配置变量 @@ -130,6 +144,15 @@ func setDefaults() { // JWT 默认值 viper.SetDefault("jwt.secret", "your-secret-key-here-change-in-production") viper.SetDefault("jwt.expire", 7200) + + // OSS 默认值 + viper.SetDefault("oss.region", "oss-cn-hangzhou") + viper.SetDefault("oss.endpoint", "oss-cn-hangzhou.aliyuncs.com") + viper.SetDefault("oss.bucket", "trace-system") + viper.SetDefault("oss.prefix", "aftersales-confirmations") + viper.SetDefault("oss.public_base_url", "") + viper.SetDefault("oss.max_file_size_mb", 5) + viper.SetDefault("oss.max_files", 6) } // bindEnvVariables 绑定环境变量 @@ -155,6 +178,17 @@ func bindEnvVariables() { // JWT 配置 viper.BindEnv("jwt.secret") viper.BindEnv("jwt.expire") + + // OSS 配置 + viper.BindEnv("oss.region") + viper.BindEnv("oss.endpoint") + viper.BindEnv("oss.bucket") + viper.BindEnv("oss.access_key_id") + viper.BindEnv("oss.access_key_secret") + viper.BindEnv("oss.prefix") + viper.BindEnv("oss.public_base_url") + viper.BindEnv("oss.max_file_size_mb") + viper.BindEnv("oss.max_files") } // validateConfig 验证配置 diff --git a/controllers/aftersales_controller.go b/controllers/aftersales_controller.go index fbfec61..108fa59 100644 --- a/controllers/aftersales_controller.go +++ b/controllers/aftersales_controller.go @@ -390,3 +390,39 @@ func (c *AftersalesController) CustomerConfirm(ctx *gin.Context) { "order": view, }) } + +// UploadSiteImages 上传客户确认现场图片 +// @Summary 上传售后现场图片 +// @Tags 售后工单查询 +// @Accept multipart/form-data +// @Produce json +// @Param serialNumber path string true "工单号" +// @Param files formData file true "现场图片" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/site-images [post] +func (c *AftersalesController) UploadSiteImages(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + form, err := ctx.MultipartForm() + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, "请选择要上传的现场图片") + return + } + + files := form.File["files"] + if len(files) == 0 { + files = form.File["file"] + } + + images, err := c.aftersalesService.UploadSiteImages(serialNumber, files) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "现场图片上传成功", gin.H{ + "siteImages": images, + }) +} diff --git a/go.mod b/go.mod index e04c1af..932fcb3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.beifan.cn/trace-system/backend-go -go 1.25 +go 1.25.0 require ( github.com/gin-contrib/cors v1.7.6 @@ -46,6 +46,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -91,6 +92,7 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 449878d..a99362d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= +github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -220,6 +222,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/models/models.go b/models/models.go index dc7a519..690ed63 100644 --- a/models/models.go +++ b/models/models.go @@ -273,6 +273,8 @@ type AftersalesOrder struct { RejectCount int `gorm:"default:0" json:"rejectCount"` Signature string `gorm:"type:text" json:"signature,omitempty"` ResponsibleSignature string `gorm:"type:text" json:"responsibleSignature,omitempty"` + SiteImagesJSON string `gorm:"type:text;column:site_images" json:"-"` + SiteImages []string `gorm:"-" json:"siteImages,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -341,4 +343,5 @@ type AftersalesPublicView struct { ConfirmedAt *time.Time `json:"confirmedAt"` Signature string `json:"signature,omitempty"` ResponsibleSignature string `json:"responsibleSignature,omitempty"` + SiteImages []string `json:"siteImages,omitempty"` } diff --git a/routes/routes.go b/routes/routes.go index 60ff37c..9d38eef 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -96,6 +96,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) { { // 公开(无需登录) aftersalesRoutes.GET("/:serialNumber/query", aftersalesController.PublicQuery) + aftersalesRoutes.POST("/:serialNumber/site-images", aftersalesController.UploadSiteImages) aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm) // 技术员 + 管理员 diff --git a/services/aftersales_service.go b/services/aftersales_service.go index a7b87f8..18014f7 100644 --- a/services/aftersales_service.go +++ b/services/aftersales_service.go @@ -2,9 +2,12 @@ package services import ( "encoding/base64" + "encoding/json" "errors" "fmt" + "mime/multipart" "os" + "path/filepath" "strings" "sync" "time" @@ -13,6 +16,7 @@ import ( qr "github.com/yeqown/go-qrcode/v2" "github.com/yeqown/go-qrcode/writer/standard" + "git.beifan.cn/trace-system/backend-go/config" "git.beifan.cn/trace-system/backend-go/database" "git.beifan.cn/trace-system/backend-go/models" ) @@ -38,6 +42,14 @@ const ( signatureMaxBytes = 500 * 1024 ) +var allowedSiteImageContentTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/webp": true, + "image/heic": true, + "image/heif": true, +} + // validateSignature 校验客户签名 dataURL 是否合法 // 接受 data:image/png;base64,... 或 data:image/jpeg;base64,... 形式 func validateSignature(s string) error { @@ -107,6 +119,36 @@ func normalizeAftersalesSerial(sn string) string { return strings.ToLower(strings.TrimSpace(sn)) } +func parseSiteImages(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var images []string + if err := json.Unmarshal([]byte(raw), &images); err != nil { + return nil + } + return images +} + +func encodeSiteImages(images []string) string { + if len(images) == 0 { + return "" + } + data, err := json.Marshal(images) + if err != nil { + return "" + } + return string(data) +} + +func hydrateAftersalesOrder(order *models.AftersalesOrder) { + if order == nil { + return + } + order.SiteImages = parseSiteImages(order.SiteImagesJSON) +} + // generateUniqueSerial 生成唯一的售后工单序列号 // 格式:zjbf-sh-YYMMDDNN,YY=年份后两位,MM=月份,DD=日期,NN=当天第几单(至少 2 位,溢出自然加宽) func (s *AftersalesService) generateUniqueSerial() (string, error) { @@ -224,6 +266,7 @@ func (s *AftersalesService) FindOne(serialNumber string) (*models.AftersalesOrde if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } + hydrateAftersalesOrder(&order) return &order, nil } @@ -342,6 +385,7 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales ConfirmedAt: order.ConfirmedAt, Signature: order.Signature, ResponsibleSignature: order.ResponsibleSignature, + SiteImages: parseSiteImages(order.SiteImagesJSON), } if order.Technician != nil { view.TechnicianName = order.Technician.Name @@ -349,6 +393,88 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales return view, nil } +// UploadSiteImages 上传客户确认现场图片到 OSS 并保存 URL。 +func (s *AftersalesService) UploadSiteImages(serialNumber string, files []*multipart.FileHeader) ([]string, error) { + normalized := normalizeAftersalesSerial(serialNumber) + if len(files) == 0 { + return nil, errors.New("请选择要上传的现场图片") + } + + cfg := config.GetAppConfig().OSS + maxFiles := cfg.MaxFiles + if maxFiles <= 0 { + maxFiles = 6 + } + maxFileSize := int64(cfg.MaxFileSizeMB) + if maxFileSize <= 0 { + maxFileSize = 5 + } + maxFileSize *= 1024 * 1024 + + var order models.AftersalesOrder + if err := database.DB.Where("serial_number = ?", normalized).First(&order).Error; err != nil { + return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) + } + if order.WorkOrderStatus != WorkOrderStatusPendingConfirmation { + return nil, errors.New("当前工单状态不可上传现场图片") + } + + existing := parseSiteImages(order.SiteImagesJSON) + if len(existing)+len(files) > maxFiles { + return nil, fmt.Errorf("现场图片最多上传 %d 张", maxFiles) + } + + ossService := NewOSSService() + uploaded := make([]string, 0, len(files)) + for _, fh := range files { + if fh.Size <= 0 { + return nil, fmt.Errorf("图片 %s 内容为空", fh.Filename) + } + if fh.Size > maxFileSize { + return nil, fmt.Errorf("图片 %s 超过 %dMB 限制", fh.Filename, maxFileSize/(1024*1024)) + } + + contentType := strings.ToLower(strings.TrimSpace(fh.Header.Get("Content-Type"))) + if !allowedSiteImageContentTypes[contentType] { + return nil, fmt.Errorf("图片 %s 格式不支持", fh.Filename) + } + + file, err := fh.Open() + if err != nil { + return nil, fmt.Errorf("读取图片 %s 失败: %w", fh.Filename, err) + } + + objectKey := buildSiteImageObjectKey(cfg.Prefix, normalized, fh.Filename) + url, err := ossService.UploadObject(objectKey, file, contentType) + _ = file.Close() + if err != nil { + return nil, err + } + uploaded = append(uploaded, url) + } + + merged := append(existing, uploaded...) + order.SiteImagesJSON = encodeSiteImages(merged) + if err := database.DB.Save(&order).Error; err != nil { + return nil, fmt.Errorf("保存现场图片失败: %w", err) + } + + return merged, nil +} + +func buildSiteImageObjectKey(prefix string, serialNumber string, filename string) string { + prefix = strings.Trim(strings.TrimSpace(prefix), "/") + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + ext = ".jpg" + } + name := uuid.NewString() + ext + if prefix == "" { + return serialNumber + "/" + name + } + return prefix + "/" + serialNumber + "/" + name +} + // CustomerConfirm 客户授权/未授权确认 func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.CustomerConfirmDTO) (*models.AftersalesPublicView, error) { normalized := normalizeAftersalesSerial(serialNumber) diff --git a/services/oss_service.go b/services/oss_service.go new file mode 100644 index 0000000..2f8eebd --- /dev/null +++ b/services/oss_service.go @@ -0,0 +1,80 @@ +package services + +import ( + "fmt" + "io" + "net/url" + "strings" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + + "git.beifan.cn/trace-system/backend-go/config" +) + +// OSSService 封装阿里云 OSS 上传。 +type OSSService struct { + cfg config.OSSConfig +} + +func NewOSSService() OSSService { + return OSSService{cfg: config.GetAppConfig().OSS} +} + +func (s OSSService) UploadObject(objectKey string, reader io.Reader, contentType 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) + } + + options := []oss.Option{} + if contentType != "" { + options = append(options, oss.ContentType(contentType)) + } + if err := bucket.PutObject(objectKey, reader, options...); err != nil { + return "", fmt.Errorf("上传现场图片失败: %w", err) + } + + return s.publicURL(objectKey), nil +} + +func (s OSSService) validateConfig() error { + if strings.TrimSpace(s.cfg.Endpoint) == "" || + strings.TrimSpace(s.cfg.Bucket) == "" || + strings.TrimSpace(s.cfg.AccessKeyID) == "" || + strings.TrimSpace(s.cfg.AccessKeySecret) == "" { + return fmt.Errorf("OSS 配置未完整,请检查 endpoint、bucket、access_key_id、access_key_secret") + } + return nil +} + +func (s OSSService) publicURL(objectKey string) string { + if base := strings.TrimRight(strings.TrimSpace(s.cfg.PublicBaseURL), "/"); base != "" { + return base + "/" + objectKey + } + endpoint := normalizeOSSEndpoint(s.cfg.Endpoint) + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" { + return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, strings.TrimPrefix(endpoint, "https://"), objectKey) + } + return fmt.Sprintf("%s://%s.%s/%s", u.Scheme, s.cfg.Bucket, u.Host, objectKey) +} + +func normalizeOSSEndpoint(endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return endpoint + } + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + return endpoint + } + return "https://" + endpoint +}