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 }