feat: add product trace APIs

This commit is contained in:
Frudrax Cheng
2026-06-05 17:21:06 +08:00
parent bb987cad62
commit efdde0ab28
5 changed files with 532 additions and 0 deletions
+197
View File
@@ -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, "产品溯源删除成功")
}
+1
View File
@@ -113,6 +113,7 @@ func AutoMigrate() {
&models.User{},
&models.Company{},
&models.Serial{},
&models.ProductTrace{},
&models.EmployeeSerial{},
&models.AftersalesOrder{},
&models.ProjectOrder{},
+46
View File
@@ -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"`
+15
View File
@@ -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")
+273
View File
@@ -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
}