Add aftersales work order feature

- AftersalesOrder model with state machine (created/pending_confirmation/closed/rejected)
- Public scan-to-confirm flow with phone last-4 verification and rate limiting
- Technician role and middleware for ownership-scoped operations
- QR code generation pointing to /aftersales/{serialNumber}
- Admin overrides: reassign, force-close, delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frudrax Cheng
2026-05-26 10:39:49 +08:00
parent e820b858bf
commit 0d82260fd9
6 changed files with 992 additions and 0 deletions
+469
View File
@@ -0,0 +1,469 @@
package services
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"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-"
)
// 客户确认接口频率限制:每分钟同一工单最多 5 次尝试
var confirmRateLimiter = struct {
sync.Mutex
attempts map[string][]time.Time
}{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 生成唯一的售后工单序列号
func (s *AftersalesService) generateUniqueSerial() (string, error) {
for attempt := 0; attempt < 10; attempt++ {
randomBytes := make([]byte, 3)
if _, err := rand.Read(randomBytes); err != nil {
return "", fmt.Errorf("生成随机数失败: %w", err)
}
randomPart := strings.ToLower(hex.EncodeToString(randomBytes))[:6]
candidate := aftersalesSerialPrefix + randomPart
var existing models.AftersalesOrder
result := database.DB.Where("serial_number = ?", candidate).First(&existing)
if result.Error != nil {
return candidate, nil
}
}
return "", errors.New("生成唯一序列号失败,请重试")
}
// Create 创建售后工单
func (s *AftersalesService) Create(dto models.CreateAftersalesOrderDTO, userId uint) (*models.AftersalesOrder, error) {
// 确保公司存在
var company models.Company
result := database.DB.Where("company_name = ?", dto.CompanyName).First(&company)
if result.Error != nil {
company = models.Company{
CompanyName: dto.CompanyName,
IsActive: true,
}
if err := database.DB.Create(&company).Error; err != nil {
return nil, fmt.Errorf("创建公司失败: %w", err)
}
}
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,
}
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("当前工单状态不可确认")
}
if len(order.ContactPhone) < 4 || order.ContactPhone[len(order.ContactPhone)-4:] != dto.PhoneLast4 {
return nil, errors.New("手机号校验失败")
}
now := time.Now()
switch dto.Action {
case "authorize":
order.WorkOrderStatus = WorkOrderStatusClosed
order.AuthorizationStatus = AuthorizationStatusAuthorized
order.ConfirmedAt = &now
case "reject":
order.WorkOrderStatus = WorkOrderStatusRejected
order.AuthorizationStatus = AuthorizationStatusUnauthorized
order.RejectCount++
if dto.RejectReason != "" {
order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + dto.RejectReason
}
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
}