Files
backend-go/services/product_traces_service.go
2026-06-05 17:21:06 +08:00

274 lines
8.3 KiB
Go

package services
import (
"encoding/base64"
"errors"
"fmt"
"mime/multipart"
"net/url"
"os"
"strings"
"github.com/google/uuid"
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"
)
// ProductTracesService 产品溯源服务
type ProductTracesService struct{}
func normalizeProductSerial(serialNumber string) string {
return strings.TrimSpace(serialNumber)
}
func hydrateProductTrace(trace *models.ProductTrace) {
if trace == nil || strings.TrimSpace(trace.WechatQRCode) == "" {
return
}
if strings.HasPrefix(trace.WechatQRCode, "http://") || strings.HasPrefix(trace.WechatQRCode, "https://") {
return
}
trace.WechatQRCode = NewOSSService().AccessURL(trace.WechatQRCode)
}
func (s *ProductTracesService) findRaw(serialNumber string) (*models.ProductTrace, error) {
var trace models.ProductTrace
if err := database.DB.Preload("Creator").Where("serial_number = ?", normalizeProductSerial(serialNumber)).First(&trace).Error; err != nil {
return nil, fmt.Errorf("查询产品溯源失败: %w", errors.New("产品序列号不存在"))
}
return &trace, nil
}
func (s *ProductTracesService) Create(dto models.CreateProductTraceDTO, userID uint) (*models.ProductTrace, error) {
serialNumber := normalizeProductSerial(dto.SerialNumber)
if serialNumber == "" {
return nil, errors.New("请填写产品序列号")
}
var existing models.ProductTrace
if err := database.DB.Unscoped().Where("serial_number = ?", serialNumber).First(&existing).Error; err == nil {
return nil, errors.New("产品序列号已存在")
}
trace := models.ProductTrace{
CompanyName: strings.TrimSpace(dto.CompanyName),
CompanyAddress: strings.TrimSpace(dto.CompanyAddress),
CompanyPhone: strings.TrimSpace(dto.CompanyPhone),
DeviceInfo: strings.TrimSpace(dto.DeviceInfo),
WarrantyPeriod: strings.TrimSpace(dto.WarrantyPeriod),
ManufactureDate: dto.ManufactureDate,
SerialNumber: serialNumber,
OfficialWebsite: strings.TrimSpace(dto.OfficialWebsite),
IsActive: true,
CreatedBy: &userID,
}
if err := database.DB.Create(&trace).Error; err != nil {
return nil, fmt.Errorf("创建产品溯源失败: %w", err)
}
_ = database.DB.Preload("Creator").Where("serial_number = ?", trace.SerialNumber).First(&trace)
hydrateProductTrace(&trace)
return &trace, nil
}
func (s *ProductTracesService) FindAll(page int, limit int, search string) ([]models.ProductTrace, int, int, error) {
var traces []models.ProductTrace
var total int64
offset := (page - 1) * limit
db := database.DB.Model(&models.ProductTrace{}).Preload("Creator")
if search != "" {
pattern := "%" + search + "%"
db = db.Where("serial_number LIKE ? OR company_name LIKE ? OR device_info LIKE ?", pattern, pattern, pattern)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, 0, fmt.Errorf("查询产品溯源总数失败: %w", err)
}
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&traces).Error; err != nil {
return nil, 0, 0, fmt.Errorf("查询产品溯源列表失败: %w", err)
}
for i := range traces {
hydrateProductTrace(&traces[i])
}
totalPages := (int(total) + limit - 1) / limit
return traces, int(total), totalPages, nil
}
func (s *ProductTracesService) FindOne(serialNumber string) (*models.ProductTrace, error) {
trace, err := s.findRaw(serialNumber)
if err != nil {
return nil, err
}
hydrateProductTrace(trace)
return trace, nil
}
func (s *ProductTracesService) PublicQuery(serialNumber string) (*models.ProductTrace, error) {
return s.FindOne(serialNumber)
}
func (s *ProductTracesService) Update(serialNumber string, dto models.UpdateProductTraceDTO) (*models.ProductTrace, error) {
trace, err := s.findRaw(serialNumber)
if err != nil {
return nil, err
}
if dto.CompanyName != "" {
trace.CompanyName = strings.TrimSpace(dto.CompanyName)
}
if dto.CompanyAddress != "" {
trace.CompanyAddress = strings.TrimSpace(dto.CompanyAddress)
}
if dto.CompanyPhone != "" {
trace.CompanyPhone = strings.TrimSpace(dto.CompanyPhone)
}
if dto.DeviceInfo != "" {
trace.DeviceInfo = strings.TrimSpace(dto.DeviceInfo)
}
if dto.WarrantyPeriod != "" {
trace.WarrantyPeriod = strings.TrimSpace(dto.WarrantyPeriod)
}
if dto.ManufactureDate != nil {
trace.ManufactureDate = *dto.ManufactureDate
}
trace.OfficialWebsite = strings.TrimSpace(dto.OfficialWebsite)
if dto.WechatQRCode != "" {
trace.WechatQRCode = strings.TrimSpace(dto.WechatQRCode)
}
if dto.IsActive != nil {
trace.IsActive = *dto.IsActive
}
if err := database.DB.Save(trace).Error; err != nil {
return nil, fmt.Errorf("更新产品溯源失败: %w", err)
}
_ = database.DB.Preload("Creator").Where("serial_number = ?", trace.SerialNumber).First(trace)
hydrateProductTrace(trace)
return trace, nil
}
func (s *ProductTracesService) UploadWechatQRCode(serialNumber string, fileHeader *multipart.FileHeader) (*models.ProductTrace, error) {
if fileHeader == nil {
return nil, errors.New("请选择公众号二维码图片")
}
normalized := normalizeProductSerial(serialNumber)
var trace models.ProductTrace
if err := database.DB.Where("serial_number = ?", normalized).First(&trace).Error; err != nil {
return nil, fmt.Errorf("查询产品溯源失败: %w", errors.New("产品序列号不存在"))
}
cfg := config.GetAppConfig().OSS
maxFileSize := int64(cfg.MaxFileSizeMB)
if maxFileSize <= 0 {
maxFileSize = 5
}
maxFileSize *= 1024 * 1024
if fileHeader.Size <= 0 {
return nil, errors.New("二维码图片内容为空")
}
if fileHeader.Size > maxFileSize {
return nil, fmt.Errorf("二维码图片超过 %dMB 限制", maxFileSize/(1024*1024))
}
contentType := strings.ToLower(strings.TrimSpace(fileHeader.Header.Get("Content-Type")))
if !allowedSiteImageContentTypes[contentType] {
return nil, errors.New("二维码图片格式不支持")
}
file, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("读取二维码图片失败: %w", err)
}
defer file.Close()
objectKey := buildSiteImageObjectKey(cfg.Prefix, "product-traces/"+normalized, fileHeader.Filename)
if err := NewOSSService().UploadObject(objectKey, file, contentType); err != nil {
return nil, err
}
trace.WechatQRCode = objectKey
if err := database.DB.Save(&trace).Error; err != nil {
return nil, fmt.Errorf("保存公众号二维码失败: %w", err)
}
_ = database.DB.Preload("Creator").Where("serial_number = ?", trace.SerialNumber).First(&trace)
hydrateProductTrace(&trace)
return &trace, nil
}
func (s *ProductTracesService) Revoke(serialNumber string) (*models.ProductTrace, error) {
trace, err := s.findRaw(serialNumber)
if err != nil {
return nil, err
}
trace.IsActive = false
if err := database.DB.Save(trace).Error; err != nil {
return nil, fmt.Errorf("停用产品溯源失败: %w", err)
}
hydrateProductTrace(trace)
return trace, nil
}
func (s *ProductTracesService) Delete(serialNumber string) error {
trace, err := s.findRaw(serialNumber)
if err != nil {
return err
}
if err := database.DB.Delete(trace).Error; err != nil {
return fmt.Errorf("删除产品溯源失败: %w", err)
}
return nil
}
func (s *ProductTracesService) GenerateQRCode(
serialNumber string,
baseURL string,
requestHost string,
protocol string,
) (string, string, error) {
trace, err := s.FindOne(serialNumber)
if err != nil {
return "", "", err
}
if baseURL == "" {
baseURL = fmt.Sprintf("%s://%s/product-traces/%s", protocol, requestHost, url.PathEscape(trace.SerialNumber))
}
filePath := fmt.Sprintf("temp_qr_%s.png", uuid.New().String())
writer, err := standard.New(filePath, standard.WithQRWidth(6))
if err != nil {
return "", "", fmt.Errorf("二维码写入器创建失败: %w", err)
}
qrc, errCode := qr.New(baseURL)
if errCode != nil {
os.Remove(filePath)
return "", "", fmt.Errorf("二维码创建失败: %w", errCode)
}
if errSave := qrc.Save(writer); errSave != nil {
os.Remove(filePath)
return "", "", fmt.Errorf("二维码保存失败: %w", errSave)
}
fileContent, errRead := os.ReadFile(filePath)
if errRead != nil {
os.Remove(filePath)
return "", "", fmt.Errorf("二维码文件读取失败: %w", errRead)
}
os.Remove(filePath)
qrCodeBase64 := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(fileContent))
return qrCodeBase64, baseURL, nil
}