Add OSS site image uploads for aftersales
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user