package services import ( "encoding/base64" "errors" "fmt" "os" "strings" "sync" "time" "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/database" "git.beifan.cn/trace-system/backend-go/models" ) // AftersalesService 售后工单服务 type AftersalesService struct{} // 工单状态常量 const ( WorkOrderStatusCreated = "created" WorkOrderStatusPendingConfirmation = "pending_confirmation" WorkOrderStatusClosed = "closed" WorkOrderStatusRejected = "rejected" AuthorizationStatusPending = "pending" AuthorizationStatusAuthorized = "authorized" AuthorizationStatusUnauthorized = "unauthorized" aftersalesSerialPrefix = "zjbf-sh-" // 签名 base64 解码后的大小限制 signatureMinBytes = 200 signatureMaxBytes = 500 * 1024 ) // validateSignature 校验客户签名 dataURL 是否合法 // 接受 data:image/png;base64,... 或 data:image/jpeg;base64,... 形式 func validateSignature(s string) error { s = strings.TrimSpace(s) if s == "" { return errors.New("签名不能为空") } var payload string switch { case strings.HasPrefix(s, "data:image/png;base64,"): payload = strings.TrimPrefix(s, "data:image/png;base64,") case strings.HasPrefix(s, "data:image/jpeg;base64,"): payload = strings.TrimPrefix(s, "data:image/jpeg;base64,") default: return errors.New("签名格式不合法") } decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { return errors.New("签名内容解码失败") } if len(decoded) < signatureMinBytes { return errors.New("签名内容过短,请重新签名") } if len(decoded) > signatureMaxBytes { return errors.New("签名内容过大,请精简后重试") } return nil } // 客户确认接口频率限制:每分钟同一工单最多 5 次尝试 var confirmRateLimiter = struct { sync.Mutex attempts map[string][]time.Time }{attempts: map[string][]time.Time{}} // ResetConfirmRateLimit 清空确认限流器(仅用于测试) func ResetConfirmRateLimit() { confirmRateLimiter.Lock() defer confirmRateLimiter.Unlock() confirmRateLimiter.attempts = map[string][]time.Time{} } func checkConfirmRateLimit(serialNumber string) bool { confirmRateLimiter.Lock() defer confirmRateLimiter.Unlock() now := time.Now() windowStart := now.Add(-time.Minute) attempts := confirmRateLimiter.attempts[serialNumber] filtered := attempts[:0] for _, t := range attempts { if t.After(windowStart) { filtered = append(filtered, t) } } if len(filtered) >= 5 { confirmRateLimiter.attempts[serialNumber] = filtered return false } filtered = append(filtered, now) confirmRateLimiter.attempts[serialNumber] = filtered return true } func normalizeAftersalesSerial(sn string) string { return strings.ToLower(strings.TrimSpace(sn)) } // generateUniqueSerial 生成唯一的售后工单序列号 // 格式:zjbf-sh-YYMMDDNN,YY=年份后两位,MM=月份,DD=日期,NN=当天第几单(至少 2 位,溢出自然加宽) func (s *AftersalesService) generateUniqueSerial() (string, error) { now := time.Now() yy := now.Year() % 100 mm := int(now.Month()) dd := now.Day() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) nextDay := dayStart.AddDate(0, 0, 1) // 统计当天已创建工单数(含软删除,避免编号回收) var count int64 if err := database.DB.Unscoped().Model(&models.AftersalesOrder{}). Where("created_at >= ? AND created_at < ?", dayStart, nextDay). Count(&count).Error; err != nil { return "", fmt.Errorf("统计当日工单数失败: %w", err) } seq := int(count) + 1 for attempt := 0; attempt < 100; attempt++ { candidate := fmt.Sprintf("%s%02d%02d%02d%02d", aftersalesSerialPrefix, yy, mm, dd, seq) var existing models.AftersalesOrder result := database.DB.Unscoped().Where("serial_number = ?", candidate).First(&existing) if result.Error != nil { return candidate, nil } seq++ } return "", errors.New("生成唯一序列号失败,请重试") } // Create 创建售后工单 func (s *AftersalesService) Create(dto models.CreateAftersalesOrderDTO, userId uint) (*models.AftersalesOrder, error) { serialNumber, err := s.generateUniqueSerial() if err != nil { return nil, err } technicianID := dto.TechnicianID if technicianID == nil { uid := userId technicianID = &uid } order := models.AftersalesOrder{ SerialNumber: serialNumber, CompanyName: dto.CompanyName, CompanyAddress: dto.CompanyAddress, ContactName: dto.ContactName, ContactPhone: dto.ContactPhone, ServiceType: dto.ServiceType, IssueDescription: dto.IssueDescription, TechnicianID: technicianID, CreatedBy: &userId, WorkOrderStatus: WorkOrderStatusCreated, AuthorizationStatus: AuthorizationStatusPending, } if err := database.DB.Create(&order).Error; err != nil { return nil, fmt.Errorf("创建售后工单失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // FindAll 获取售后工单列表 func (s *AftersalesService) FindAll( page int, limit int, search string, workOrderStatus string, serviceType string, technicianID *uint, ) ([]models.AftersalesOrder, int, int, error) { var orders []models.AftersalesOrder var total int64 offset := (page - 1) * limit db := database.DB.Model(&models.AftersalesOrder{}).Preload("Technician").Preload("Creator") if search != "" { pattern := "%" + search + "%" db = db.Where("serial_number LIKE ? OR company_name LIKE ? OR contact_name LIKE ?", pattern, pattern, pattern) } if workOrderStatus != "" { db = db.Where("work_order_status = ?", workOrderStatus) } if serviceType != "" { db = db.Where("service_type = ?", serviceType) } if technicianID != nil { db = db.Where("technician_id = ?", *technicianID) } 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(&orders).Error; err != nil { return nil, 0, 0, fmt.Errorf("查询售后工单列表失败: %w", err) } totalPages := (int(total) + limit - 1) / limit return orders, int(total), totalPages, nil } // FindOne 获取单个售后工单 func (s *AftersalesService) FindOne(serialNumber string) (*models.AftersalesOrder, error) { var order models.AftersalesOrder result := database.DB.Preload("Technician").Preload("Creator"). Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } return &order, nil } // Update 更新售后工单基础信息 func (s *AftersalesService) Update( serialNumber string, dto models.UpdateAftersalesOrderDTO, currentUser models.User, ) (*models.AftersalesOrder, error) { var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == WorkOrderStatusClosed { return nil, errors.New("工单已关闭,不可修改") } if currentUser.Role != "admin" { if order.TechnicianID == nil || *order.TechnicianID != currentUser.ID { return nil, errors.New("无权修改此工单") } } if dto.CompanyAddress != "" { order.CompanyAddress = dto.CompanyAddress } if dto.ContactName != "" { order.ContactName = dto.ContactName } if dto.ContactPhone != "" { order.ContactPhone = dto.ContactPhone } if dto.ServiceType != "" { order.ServiceType = dto.ServiceType } if dto.IssueDescription != "" { order.IssueDescription = dto.IssueDescription } if dto.ResolutionNote != "" { order.ResolutionNote = dto.ResolutionNote } if dto.TechnicianID != nil && currentUser.Role == "admin" { order.TechnicianID = dto.TechnicianID } if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("更新售后工单失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // SubmitForConfirmation 技术员提交客户确认 func (s *AftersalesService) SubmitForConfirmation( serialNumber string, dto models.SubmitForConfirmationDTO, currentUser models.User, ) (*models.AftersalesOrder, error) { var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } if currentUser.Role != "admin" { if order.TechnicianID == nil || *order.TechnicianID != currentUser.ID { return nil, errors.New("无权操作此工单") } } if order.WorkOrderStatus != WorkOrderStatusCreated && order.WorkOrderStatus != WorkOrderStatusRejected { return nil, errors.New("当前工单状态不可提交确认") } order.ResolutionNote = dto.ResolutionNote order.WorkOrderStatus = WorkOrderStatusPendingConfirmation if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("提交客户确认失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // PublicQuery 公开查询(脱敏视图) func (s *AftersalesService) PublicQuery(serialNumber string) (*models.AftersalesPublicView, error) { var order models.AftersalesOrder result := database.DB.Preload("Technician"). Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } // 首次扫码记录时间 if order.ScannedAt == nil { now := time.Now() order.ScannedAt = &now _ = database.DB.Model(&order).Update("scanned_at", now).Error } view := &models.AftersalesPublicView{ SerialNumber: order.SerialNumber, CompanyName: order.CompanyName, CompanyAddress: order.CompanyAddress, ContactName: order.ContactName, ServiceType: order.ServiceType, IssueDescription: order.IssueDescription, ResolutionNote: order.ResolutionNote, WorkOrderStatus: order.WorkOrderStatus, AuthorizationStatus: order.AuthorizationStatus, CreatedAt: order.CreatedAt, ConfirmedAt: order.ConfirmedAt, Signature: order.Signature, } if order.Technician != nil { view.TechnicianName = order.Technician.Name } return view, nil } // CustomerConfirm 客户授权/未授权确认 func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.CustomerConfirmDTO) (*models.AftersalesPublicView, error) { normalized := normalizeAftersalesSerial(serialNumber) if !checkConfirmRateLimit(normalized) { return nil, errors.New("操作过于频繁,请稍后再试") } var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalized).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus != WorkOrderStatusPendingConfirmation { return nil, errors.New("当前工单状态不可确认") } now := time.Now() switch dto.Action { case "authorize": if err := validateSignature(dto.Signature); err != nil { return nil, err } order.WorkOrderStatus = WorkOrderStatusClosed order.AuthorizationStatus = AuthorizationStatusAuthorized order.ConfirmedAt = &now order.Signature = strings.TrimSpace(dto.Signature) case "reject": reason := strings.TrimSpace(dto.RejectReason) if reason == "" { return nil, errors.New("请填写退回原因") } order.WorkOrderStatus = WorkOrderStatusRejected order.AuthorizationStatus = AuthorizationStatusUnauthorized order.RejectCount++ order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + reason default: return nil, errors.New("无效的操作") } if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("提交确认失败: %w", err) } return s.PublicQuery(normalized) } // Reassign 重新分配技术员(仅管理员) func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*models.AftersalesOrder, error) { var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == WorkOrderStatusClosed { return nil, errors.New("工单已关闭,不可重新分配") } var technician models.User if err := database.DB.First(&technician, technicianID).Error; err != nil { return nil, errors.New("指定的技术员不存在") } if technician.Role != "admin" && technician.Role != "technician" { return nil, errors.New("指定的用户不是技术员或管理员") } order.TechnicianID = &technicianID if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("重新分配技术员失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // ForceClose 管理员强制关闭工单 func (s *AftersalesService) ForceClose(serialNumber string) (*models.AftersalesOrder, error) { var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == WorkOrderStatusClosed { return nil, errors.New("工单已关闭") } now := time.Now() order.WorkOrderStatus = WorkOrderStatusClosed order.AuthorizationStatus = AuthorizationStatusAuthorized order.ConfirmedAt = &now if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("强制关闭工单失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // Delete 物理删除售后工单(仅管理员) func (s *AftersalesService) Delete(serialNumber string) error { var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } if err := database.DB.Delete(&order).Error; err != nil { return fmt.Errorf("删除售后工单失败: %w", err) } return nil } // GenerateQRCode 生成售后工单二维码 func (s *AftersalesService) GenerateQRCode( serialNumber string, baseUrl string, requestHost string, protocol string, ) (string, string, error) { var order models.AftersalesOrder result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) if result.Error != nil { return "", "", fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在")) } // 售后码二维码直接指向 /aftersales/{serialNumber} if baseUrl == "" { baseUrl = fmt.Sprintf("%s://%s/aftersales/%s", protocol, requestHost, order.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 }