Add OSS site image uploads for aftersales
This commit is contained in:
+12
-1
@@ -19,4 +19,15 @@ APP_DATABASE_POSTGRES_SSLMODE=disable
|
|||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
APP_JWT_SECRET=your-secret-key-here-change-in-production
|
APP_JWT_SECRET=your-secret-key-here-change-in-production
|
||||||
APP_JWT_EXPIRE=7200
|
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
|
||||||
|
|||||||
+12
@@ -20,3 +20,15 @@ database:
|
|||||||
jwt:
|
jwt:
|
||||||
secret: "your-secret-key-here-change-in-production"
|
secret: "your-secret-key-here-change-in-production"
|
||||||
expire: 7200 # 过期时间(秒)
|
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
|
||||||
|
|||||||
@@ -43,11 +43,25 @@ type JWTConfig struct {
|
|||||||
Expire int `mapstructure:"expire"`
|
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 应用程序配置
|
// AppConfig 应用程序配置
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
|
OSS OSSConfig `mapstructure:"oss"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局配置变量
|
// 全局配置变量
|
||||||
@@ -130,6 +144,15 @@ func setDefaults() {
|
|||||||
// JWT 默认值
|
// JWT 默认值
|
||||||
viper.SetDefault("jwt.secret", "your-secret-key-here-change-in-production")
|
viper.SetDefault("jwt.secret", "your-secret-key-here-change-in-production")
|
||||||
viper.SetDefault("jwt.expire", 7200)
|
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 绑定环境变量
|
// bindEnvVariables 绑定环境变量
|
||||||
@@ -155,6 +178,17 @@ func bindEnvVariables() {
|
|||||||
// JWT 配置
|
// JWT 配置
|
||||||
viper.BindEnv("jwt.secret")
|
viper.BindEnv("jwt.secret")
|
||||||
viper.BindEnv("jwt.expire")
|
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 验证配置
|
// validateConfig 验证配置
|
||||||
|
|||||||
@@ -390,3 +390,39 @@ func (c *AftersalesController) CustomerConfirm(ctx *gin.Context) {
|
|||||||
"order": view,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module git.beifan.cn/trace-system/backend-go
|
module git.beifan.cn/trace-system/backend-go
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.7.6
|
github.com/gin-contrib/cors v1.7.6
|
||||||
@@ -46,6 +46,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
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/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/net v0.50.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.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
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
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.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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ type AftersalesOrder struct {
|
|||||||
RejectCount int `gorm:"default:0" json:"rejectCount"`
|
RejectCount int `gorm:"default:0" json:"rejectCount"`
|
||||||
Signature string `gorm:"type:text" json:"signature,omitempty"`
|
Signature string `gorm:"type:text" json:"signature,omitempty"`
|
||||||
ResponsibleSignature string `gorm:"type:text" json:"responsibleSignature,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"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -341,4 +343,5 @@ type AftersalesPublicView struct {
|
|||||||
ConfirmedAt *time.Time `json:"confirmedAt"`
|
ConfirmedAt *time.Time `json:"confirmedAt"`
|
||||||
Signature string `json:"signature,omitempty"`
|
Signature string `json:"signature,omitempty"`
|
||||||
ResponsibleSignature string `json:"responsibleSignature,omitempty"`
|
ResponsibleSignature string `json:"responsibleSignature,omitempty"`
|
||||||
|
SiteImages []string `json:"siteImages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
|
|||||||
{
|
{
|
||||||
// 公开(无需登录)
|
// 公开(无需登录)
|
||||||
aftersalesRoutes.GET("/:serialNumber/query", aftersalesController.PublicQuery)
|
aftersalesRoutes.GET("/:serialNumber/query", aftersalesController.PublicQuery)
|
||||||
|
aftersalesRoutes.POST("/:serialNumber/site-images", aftersalesController.UploadSiteImages)
|
||||||
aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm)
|
aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm)
|
||||||
|
|
||||||
// 技术员 + 管理员
|
// 技术员 + 管理员
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,6 +16,7 @@ import (
|
|||||||
qr "github.com/yeqown/go-qrcode/v2"
|
qr "github.com/yeqown/go-qrcode/v2"
|
||||||
"github.com/yeqown/go-qrcode/writer/standard"
|
"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/database"
|
||||||
"git.beifan.cn/trace-system/backend-go/models"
|
"git.beifan.cn/trace-system/backend-go/models"
|
||||||
)
|
)
|
||||||
@@ -38,6 +42,14 @@ const (
|
|||||||
signatureMaxBytes = 500 * 1024
|
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 是否合法
|
// validateSignature 校验客户签名 dataURL 是否合法
|
||||||
// 接受 data:image/png;base64,... 或 data:image/jpeg;base64,... 形式
|
// 接受 data:image/png;base64,... 或 data:image/jpeg;base64,... 形式
|
||||||
func validateSignature(s string) error {
|
func validateSignature(s string) error {
|
||||||
@@ -107,6 +119,36 @@ func normalizeAftersalesSerial(sn string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(sn))
|
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 生成唯一的售后工单序列号
|
// generateUniqueSerial 生成唯一的售后工单序列号
|
||||||
// 格式:zjbf-sh-YYMMDDNN,YY=年份后两位,MM=月份,DD=日期,NN=当天第几单(至少 2 位,溢出自然加宽)
|
// 格式:zjbf-sh-YYMMDDNN,YY=年份后两位,MM=月份,DD=日期,NN=当天第几单(至少 2 位,溢出自然加宽)
|
||||||
func (s *AftersalesService) generateUniqueSerial() (string, error) {
|
func (s *AftersalesService) generateUniqueSerial() (string, error) {
|
||||||
@@ -224,6 +266,7 @@ func (s *AftersalesService) FindOne(serialNumber string) (*models.AftersalesOrde
|
|||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
}
|
}
|
||||||
|
hydrateAftersalesOrder(&order)
|
||||||
return &order, nil
|
return &order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +385,7 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales
|
|||||||
ConfirmedAt: order.ConfirmedAt,
|
ConfirmedAt: order.ConfirmedAt,
|
||||||
Signature: order.Signature,
|
Signature: order.Signature,
|
||||||
ResponsibleSignature: order.ResponsibleSignature,
|
ResponsibleSignature: order.ResponsibleSignature,
|
||||||
|
SiteImages: parseSiteImages(order.SiteImagesJSON),
|
||||||
}
|
}
|
||||||
if order.Technician != nil {
|
if order.Technician != nil {
|
||||||
view.TechnicianName = order.Technician.Name
|
view.TechnicianName = order.Technician.Name
|
||||||
@@ -349,6 +393,88 @@ func (s *AftersalesService) PublicQuery(serialNumber string) (*models.Aftersales
|
|||||||
return view, nil
|
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 客户授权/未授权确认
|
// CustomerConfirm 客户授权/未授权确认
|
||||||
func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.CustomerConfirmDTO) (*models.AftersalesPublicView, error) {
|
func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.CustomerConfirmDTO) (*models.AftersalesPublicView, error) {
|
||||||
normalized := normalizeAftersalesSerial(serialNumber)
|
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