From efdde0ab28afb1be387765bbf5a427784170d982 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Fri, 5 Jun 2026 17:21:06 +0800 Subject: [PATCH] feat: add product trace APIs --- controllers/product_traces_controller.go | 197 ++++++++++++++++ database/database.go | 1 + models/models.go | 46 ++++ routes/routes.go | 15 ++ services/product_traces_service.go | 273 +++++++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 controllers/product_traces_controller.go create mode 100644 services/product_traces_service.go diff --git a/controllers/product_traces_controller.go b/controllers/product_traces_controller.go new file mode 100644 index 0000000..b983eb2 --- /dev/null +++ b/controllers/product_traces_controller.go @@ -0,0 +1,197 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/models" + "git.beifan.cn/trace-system/backend-go/services" +) + +// ProductTracesController 产品溯源控制器 +type ProductTracesController struct { + productTracesService services.ProductTracesService +} + +// NewProductTracesController 创建产品溯源控制器实例 +func NewProductTracesController() *ProductTracesController { + return &ProductTracesController{ + productTracesService: services.ProductTracesService{}, + } +} + +// Create 创建产品溯源 +func (c *ProductTracesController) Create(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var dto models.CreateProductTraceDTO + if !BindJSON(ctx, &dto) { + return + } + + trace, err := c.productTracesService.Create(dto, userModel.ID) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "产品溯源创建成功", gin.H{ + "trace": trace, + }) +} + +// FindAll 获取产品溯源列表 +func (c *ProductTracesController) FindAll(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + search := ctx.DefaultQuery("search", "") + + traces, total, totalPages, err := c.productTracesService.FindAll(page, limit, search) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取产品溯源列表成功", gin.H{ + "data": traces, + "pagination": gin.H{ + "page": page, + "limit": limit, + "total": total, + "totalPages": totalPages, + }, + }) +} + +// FindOne 获取产品溯源详情 +func (c *ProductTracesController) FindOne(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + trace, err := c.productTracesService.FindOne(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "trace": trace, + }) +} + +// Update 更新产品溯源 +func (c *ProductTracesController) Update(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + var dto models.UpdateProductTraceDTO + if !BindJSON(ctx, &dto) { + return + } + + trace, err := c.productTracesService.Update(serialNumber, dto) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "产品溯源更新成功", gin.H{ + "trace": trace, + }) +} + +// PublicQuery 公开查询产品溯源 +func (c *ProductTracesController) PublicQuery(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + trace, err := c.productTracesService.PublicQuery(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "trace": trace, + }) +} + +// GenerateQRCode 生成产品溯源二维码 +func (c *ProductTracesController) GenerateQRCode(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + var qrCodeData models.QRCodeDTO + if !BindJSON(ctx, &qrCodeData) { + return + } + + protocol := "http" + if ctx.Request.TLS != nil { + protocol = "https" + } + + qrCodeBase64, queryURL, err := c.productTracesService.GenerateQRCode( + serialNumber, + qrCodeData.BaseUrl, + ctx.Request.Host, + protocol, + ) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "二维码生成成功", gin.H{ + "qrCodeData": qrCodeBase64, + "queryUrl": queryURL, + }) +} + +// UploadWechatQRCode 上传公众号二维码 +func (c *ProductTracesController) UploadWechatQRCode(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + fileHeader, err := ctx.FormFile("file") + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, "请选择公众号二维码图片") + return + } + + trace, err := c.productTracesService.UploadWechatQRCode(serialNumber, fileHeader) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "公众号二维码上传成功", gin.H{ + "trace": trace, + }) +} + +// Revoke 停用产品溯源 +func (c *ProductTracesController) Revoke(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + trace, err := c.productTracesService.Revoke(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "产品溯源已停用", gin.H{ + "trace": trace, + }) +} + +// Delete 删除产品溯源 +func (c *ProductTracesController) Delete(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + if err := c.productTracesService.Delete(serialNumber); err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "产品溯源删除成功") +} diff --git a/database/database.go b/database/database.go index 19cf974..6e1004d 100644 --- a/database/database.go +++ b/database/database.go @@ -113,6 +113,7 @@ func AutoMigrate() { &models.User{}, &models.Company{}, &models.Serial{}, + &models.ProductTrace{}, &models.EmployeeSerial{}, &models.AftersalesOrder{}, &models.ProjectOrder{}, diff --git a/models/models.go b/models/models.go index c1b0007..4ea7665 100644 --- a/models/models.go +++ b/models/models.go @@ -50,6 +50,26 @@ type Serial struct { Company *Company `gorm:"foreignKey:CompanyName;references:CompanyName" json:"company,omitempty"` } +// ProductTrace 产品溯源模型 +type ProductTrace struct { + ID uint `gorm:"primaryKey" json:"id"` + CompanyName string `gorm:"index;size:255" json:"companyName"` + CompanyAddress string `gorm:"size:500" json:"companyAddress"` + CompanyPhone string `gorm:"size:32" json:"companyPhone"` + DeviceInfo string `gorm:"type:text" json:"deviceInfo"` + WarrantyPeriod string `gorm:"size:100" json:"warrantyPeriod"` + ManufactureDate time.Time `json:"manufactureDate"` + SerialNumber string `gorm:"uniqueIndex;size:255" json:"serialNumber"` + OfficialWebsite string `gorm:"size:500" json:"officialWebsite,omitempty"` + WechatQRCode string `gorm:"type:text" json:"wechatQrCode,omitempty"` + IsActive bool `gorm:"default:true" json:"isActive"` + CreatedBy *uint `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"` +} + // UserDTO 数据传输对象 type UserDTO struct { ID uint `json:"id"` @@ -131,6 +151,32 @@ type UpdateSerialDTO struct { IsActive *bool `json:"isActive,omitempty"` } +// CreateProductTraceDTO 创建产品溯源请求数据 +type CreateProductTraceDTO struct { + CompanyName string `json:"companyName" validate:"required"` + CompanyAddress string `json:"companyAddress" validate:"required"` + CompanyPhone string `json:"companyPhone" validate:"required"` + DeviceInfo string `json:"deviceInfo" validate:"required"` + WarrantyPeriod string `json:"warrantyPeriod" validate:"required"` + ManufactureDate time.Time `json:"manufactureDate" validate:"required"` + SerialNumber string `json:"serialNumber" validate:"required"` + OfficialWebsite string `json:"officialWebsite,omitempty" validate:"omitempty,url"` + WechatQRCode string `json:"wechatQrCode,omitempty"` +} + +// UpdateProductTraceDTO 更新产品溯源请求数据 +type UpdateProductTraceDTO struct { + CompanyName string `json:"companyName,omitempty"` + CompanyAddress string `json:"companyAddress,omitempty"` + CompanyPhone string `json:"companyPhone,omitempty"` + DeviceInfo string `json:"deviceInfo,omitempty"` + WarrantyPeriod string `json:"warrantyPeriod,omitempty"` + ManufactureDate *time.Time `json:"manufactureDate,omitempty"` + OfficialWebsite string `json:"officialWebsite,omitempty" validate:"omitempty,url"` + WechatQRCode string `json:"wechatQrCode,omitempty"` + IsActive *bool `json:"isActive,omitempty"` +} + // QRCodeDTO 二维码生成请求数据 type QRCodeDTO struct { BaseUrl string `json:"baseUrl,omitempty"` diff --git a/routes/routes.go b/routes/routes.go index 2ecd39b..e404ff3 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -60,6 +60,21 @@ func SetupAPIRoutes(r *gin.RouterGroup) { companiesRoutes.DELETE("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Delete) } + // 产品溯源路由 + productTracesController := controllers.NewProductTracesController() + productTracesRoutes := r.Group("/product-traces") + { + productTracesRoutes.GET("/:serialNumber/query", productTracesController.PublicQuery) + productTracesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.Create) + productTracesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.FindAll) + productTracesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.FindOne) + productTracesRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.Update) + productTracesRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.GenerateQRCode) + productTracesRoutes.POST("/:serialNumber/wechat-qrcode", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.UploadWechatQRCode) + productTracesRoutes.POST("/:serialNumber/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.Revoke) + productTracesRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), productTracesController.Delete) + } + // 员工赋码路由 employeeSerialsController := controllers.NewEmployeeSerialsController() employeeSerialsRoutes := r.Group("/employee-serials") diff --git a/services/product_traces_service.go b/services/product_traces_service.go new file mode 100644 index 0000000..68a93d3 --- /dev/null +++ b/services/product_traces_service.go @@ -0,0 +1,273 @@ +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 +}