Files
backend-go/services/aftersales_service.go
T
2026-06-02 11:04:25 +08:00

643 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"os"
"path/filepath"
"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/config"
"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
)
var allowedSiteImageContentTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
}
// 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))
}
func parseSiteImages(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var images []string
if err := json.Unmarshal([]byte(raw), &images); err != nil {
return nil
}
return images
}
func encodeSiteImages(images []string) string {
if len(images) == 0 {
return ""
}
data, err := json.Marshal(images)
if err != nil {
return ""
}
return string(data)
}
func hydrateAftersalesOrder(order *models.AftersalesOrder) {
if order == nil {
return
}
order.SiteImages = parseSiteImages(order.SiteImagesJSON)
}
// generateUniqueSerial 生成唯一的售后工单序列号
// 格式:zjbf-sh-YYMMDDNNYY=年份后两位,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("序列号不存在"))
}
hydrateAftersalesOrder(&order)
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,
ResponsibleSignature: order.ResponsibleSignature,
SiteImages: parseSiteImages(order.SiteImagesJSON),
}
if order.Technician != nil {
view.TechnicianName = order.Technician.Name
}
return view, nil
}
// UploadSiteImages 上传客户确认现场图片到 OSS 并保存 URL。
func (s *AftersalesService) UploadSiteImages(serialNumber string, files []*multipart.FileHeader) ([]string, error) {
normalized := normalizeAftersalesSerial(serialNumber)
if len(files) == 0 {
return nil, errors.New("请选择要上传的现场图片")
}
cfg := config.GetAppConfig().OSS
maxFiles := cfg.MaxFiles
if maxFiles <= 0 {
maxFiles = 6
}
maxFileSize := int64(cfg.MaxFileSizeMB)
if maxFileSize <= 0 {
maxFileSize = 5
}
maxFileSize *= 1024 * 1024
var order models.AftersalesOrder
if err := database.DB.Where("serial_number = ?", normalized).First(&order).Error; err != nil {
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
}
if order.WorkOrderStatus != WorkOrderStatusPendingConfirmation {
return nil, errors.New("当前工单状态不可上传现场图片")
}
existing := parseSiteImages(order.SiteImagesJSON)
if len(existing)+len(files) > maxFiles {
return nil, fmt.Errorf("现场图片最多上传 %d 张", maxFiles)
}
ossService := NewOSSService()
uploaded := make([]string, 0, len(files))
for _, fh := range files {
if fh.Size <= 0 {
return nil, fmt.Errorf("图片 %s 内容为空", fh.Filename)
}
if fh.Size > maxFileSize {
return nil, fmt.Errorf("图片 %s 超过 %dMB 限制", fh.Filename, maxFileSize/(1024*1024))
}
contentType := strings.ToLower(strings.TrimSpace(fh.Header.Get("Content-Type")))
if !allowedSiteImageContentTypes[contentType] {
return nil, fmt.Errorf("图片 %s 格式不支持", fh.Filename)
}
file, err := fh.Open()
if err != nil {
return nil, fmt.Errorf("读取图片 %s 失败: %w", fh.Filename, err)
}
objectKey := buildSiteImageObjectKey(cfg.Prefix, normalized, fh.Filename)
url, err := ossService.UploadObject(objectKey, file, contentType)
_ = file.Close()
if err != nil {
return nil, err
}
uploaded = append(uploaded, url)
}
merged := append(existing, uploaded...)
order.SiteImagesJSON = encodeSiteImages(merged)
if err := database.DB.Save(&order).Error; err != nil {
return nil, fmt.Errorf("保存现场图片失败: %w", err)
}
return merged, nil
}
func buildSiteImageObjectKey(prefix string, serialNumber string, filename string) string {
prefix = strings.Trim(strings.TrimSpace(prefix), "/")
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
ext = ".jpg"
}
name := uuid.NewString() + ext
if prefix == "" {
return serialNumber + "/" + name
}
return prefix + "/" + serialNumber + "/" + name
}
// 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
}
if err := validateSignature(dto.ResponsibleSignature); err != nil {
return nil, err
}
order.WorkOrderStatus = WorkOrderStatusClosed
order.AuthorizationStatus = AuthorizationStatusAuthorized
order.ConfirmedAt = &now
order.Signature = strings.TrimSpace(dto.Signature)
order.ResponsibleSignature = strings.TrimSpace(dto.ResponsibleSignature)
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
}