Add OSS site image uploads for aftersales

This commit is contained in:
Frudrax Cheng
2026-06-02 11:04:25 +08:00
parent 1ebec18869
commit 35cd939b92
10 changed files with 311 additions and 2 deletions
+126
View File
@@ -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-YYMMDDNNYY=年份后两位,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)