diff --git a/controllers/aftersales_controller.go b/controllers/aftersales_controller.go new file mode 100644 index 0000000..fbfec61 --- /dev/null +++ b/controllers/aftersales_controller.go @@ -0,0 +1,392 @@ +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" +) + +// AftersalesController 售后工单控制器 +type AftersalesController struct { + aftersalesService services.AftersalesService +} + +// NewAftersalesController 创建售后工单控制器实例 +func NewAftersalesController() *AftersalesController { + return &AftersalesController{ + aftersalesService: services.AftersalesService{}, + } +} + +// Create 创建售后工单 +// @Summary 创建售后工单 +// @Description 创建一个新的售后工单并分配编号 +// @Tags 售后工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param data body models.CreateAftersalesOrderDTO true "工单数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /aftersales [post] +func (c *AftersalesController) Create(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var dto models.CreateAftersalesOrderDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.aftersalesService.Create(dto, userModel.ID) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "售后工单创建成功", gin.H{ + "order": order, + }) +} + +// FindAll 获取售后工单列表 +// @Summary 获取售后工单列表 +// @Description 支持分页、搜索、按状态/服务类型/技术员筛选 +// @Tags 售后工单 +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param limit query int false "每页数量" +// @Param search query string false "搜索关键词" +// @Param workOrderStatus query string false "工单状态" +// @Param serviceType query string false "服务类型" +// @Param technicianId query int false "技术员 ID" +// @Param mine query bool false "仅查看自己负责的工单" +// @Success 200 {object} models.PaginationResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /aftersales [get] +func (c *AftersalesController) FindAll(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + search := ctx.DefaultQuery("search", "") + workOrderStatus := ctx.DefaultQuery("workOrderStatus", "") + serviceType := ctx.DefaultQuery("serviceType", "") + + var technicianID *uint + if tidStr := ctx.Query("technicianId"); tidStr != "" { + if tid, err := strconv.ParseUint(tidStr, 10, 32); err == nil { + t := uint(tid) + technicianID = &t + } + } + // 非管理员默认只看自己的工单(除非显式指定 technicianId) + if userModel.Role != "admin" && technicianID == nil { + technicianID = &userModel.ID + } + // mine=true 强制只看自己的 + if ctx.Query("mine") == "true" { + technicianID = &userModel.ID + } + + orders, total, totalPages, err := c.aftersalesService.FindAll(page, limit, search, workOrderStatus, serviceType, technicianID) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取售后工单列表成功", gin.H{ + "data": orders, + "pagination": gin.H{ + "page": page, + "limit": limit, + "total": total, + "totalPages": totalPages, + }, + }) +} + +// FindOne 获取单个售后工单详情 +// @Summary 获取售后工单详情 +// @Tags 售后工单 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.DataResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber} [get] +func (c *AftersalesController) FindOne(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + order, err := c.aftersalesService.FindOne(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "order": order, + }) +} + +// Update 更新售后工单信息 +// @Summary 更新售后工单 +// @Tags 售后工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.UpdateAftersalesOrderDTO true "更新数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 403 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber} [patch] +func (c *AftersalesController) Update(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + serialNumber := ctx.Param("serialNumber") + var dto models.UpdateAftersalesOrderDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.aftersalesService.Update(serialNumber, dto, userModel) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "工单更新成功", gin.H{ + "order": order, + }) +} + +// SubmitForConfirmation 技术员提交客户确认 +// @Summary 提交客户确认 +// @Description 技术员填写处理结果后提交,工单进入"待客户确认"状态 +// @Tags 售后工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.SubmitForConfirmationDTO true "处理结果" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/submit [post] +func (c *AftersalesController) SubmitForConfirmation(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + serialNumber := ctx.Param("serialNumber") + var dto models.SubmitForConfirmationDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.aftersalesService.SubmitForConfirmation(serialNumber, dto, userModel) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "已提交客户确认", gin.H{ + "order": order, + }) +} + +// GenerateQRCode 生成售后工单二维码 +// @Summary 生成售后工单二维码 +// @Tags 售后工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.QRCodeDTO false "二维码参数" +// @Success 200 {object} models.QRCodeResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/qrcode [post] +func (c *AftersalesController) 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.aftersalesService.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, + }) +} + +// Reassign 重新分配技术员(仅管理员) +// @Summary 重新分配技术员 +// @Tags 售后工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.ReassignAftersalesDTO true "新技术员 ID" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/reassign [post] +func (c *AftersalesController) Reassign(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + var dto models.ReassignAftersalesDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.aftersalesService.Reassign(serialNumber, dto.TechnicianID) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "重新分配成功", gin.H{ + "order": order, + }) +} + +// ForceClose 强制关闭工单(仅管理员) +// @Summary 强制关闭工单 +// @Tags 售后工单 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/force-close [post] +func (c *AftersalesController) ForceClose(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + order, err := c.aftersalesService.ForceClose(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "工单已强制关闭", gin.H{ + "order": order, + }) +} + +// Delete 删除售后工单(仅管理员) +// @Summary 删除售后工单 +// @Tags 售后工单 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.BaseResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber} [delete] +func (c *AftersalesController) Delete(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + if err := c.aftersalesService.Delete(serialNumber); err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "售后工单删除成功") +} + +// PublicQuery 公开查询售后工单(无需登录,脱敏) +// @Summary 公开查询售后工单 +// @Tags 售后工单查询 +// @Produce json +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.DataResponse +// @Failure 404 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/query [get] +func (c *AftersalesController) PublicQuery(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + view, err := c.aftersalesService.PublicQuery(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "order": view, + }) +} + +// CustomerConfirm 客户授权/未授权确认 +// @Summary 客户授权确认 +// @Description 客户输入手机号后四位后选择已授权或未授权 +// @Tags 售后工单查询 +// @Accept json +// @Produce json +// @Param serialNumber path string true "工单号" +// @Param data body models.CustomerConfirmDTO true "确认数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 429 {object} models.ErrorResponse +// @Router /aftersales/{serialNumber}/confirm [post] +func (c *AftersalesController) CustomerConfirm(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + var dto models.CustomerConfirmDTO + if !BindJSON(ctx, &dto) { + return + } + + view, err := c.aftersalesService.CustomerConfirm(serialNumber, dto) + if err != nil { + // 频率限制单独返回 429 + if err.Error() == "操作过于频繁,请稍后再试" { + ErrorResponse(ctx, http.StatusTooManyRequests, err.Error()) + return + } + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "提交成功", gin.H{ + "order": view, + }) +} diff --git a/database/database.go b/database/database.go index 2512f6e..3b425b7 100644 --- a/database/database.go +++ b/database/database.go @@ -114,6 +114,7 @@ func AutoMigrate() { &models.Company{}, &models.Serial{}, &models.EmployeeSerial{}, + &models.AftersalesOrder{}, ); err != nil { logger.Fatal("数据库迁移失败", logger.Err(err)) } diff --git a/middleware/auth.go b/middleware/auth.go index c029154..d71b644 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -110,3 +110,26 @@ func AdminMiddleware() gin.HandlerFunc { c.Next() } } + +// TechnicianMiddleware 技术员权限中间件(放行 admin 和 technician) +func TechnicianMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + user, exists := c.Get("user") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "未认证", + }) + return + } + + userModel := user.(models.User) + if userModel.Role != "admin" && userModel.Role != "technician" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "message": "无权限访问此资源", + }) + return + } + + c.Next() + } +} diff --git a/models/models.go b/models/models.go index 571bad1..f7326a2 100644 --- a/models/models.go +++ b/models/models.go @@ -209,3 +209,88 @@ type UpdateEmployeeSerialDTO struct { EmployeeName string `json:"employeeName,omitempty" validate:"omitempty"` IsActive *bool `json:"isActive,omitempty"` } + +// AftersalesOrder 售后工单模型 +type AftersalesOrder struct { + ID uint `gorm:"primaryKey" json:"id"` + SerialNumber string `gorm:"uniqueIndex;size:64" json:"serialNumber"` + CompanyName string `gorm:"index;size:255" json:"companyName"` + CompanyAddress string `gorm:"size:500" json:"companyAddress"` + ContactName string `gorm:"size:100" json:"contactName"` + ContactPhone string `gorm:"size:32" json:"contactPhone"` + ServiceType string `gorm:"size:32" json:"serviceType"` + IssueDescription string `gorm:"type:text" json:"issueDescription"` + ResolutionNote string `gorm:"type:text" json:"resolutionNote"` + + WorkOrderStatus string `gorm:"size:32;default:'created'" json:"workOrderStatus"` + AuthorizationStatus string `gorm:"size:32;default:'pending'" json:"authorizationStatus"` + + TechnicianID *uint `json:"technicianId"` + CreatedBy *uint `json:"createdBy"` + ScannedAt *time.Time `json:"scannedAt"` + ConfirmedAt *time.Time `json:"confirmedAt"` + RejectCount int `gorm:"default:0" json:"rejectCount"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Technician *User `gorm:"foreignKey:TechnicianID" json:"technician,omitempty"` + Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"` + Company *Company `gorm:"foreignKey:CompanyName;references:CompanyName" json:"company,omitempty"` +} + +// CreateAftersalesOrderDTO 创建售后工单请求数据 +type CreateAftersalesOrderDTO struct { + CompanyName string `json:"companyName" validate:"required"` + CompanyAddress string `json:"companyAddress" validate:"required"` + ContactName string `json:"contactName" validate:"required"` + ContactPhone string `json:"contactPhone" validate:"required,len=11"` + ServiceType string `json:"serviceType" validate:"required,oneof=software hardware other"` + IssueDescription string `json:"issueDescription" validate:"required"` + TechnicianID *uint `json:"technicianId,omitempty"` +} + +// UpdateAftersalesOrderDTO 更新售后工单请求数据 +type UpdateAftersalesOrderDTO struct { + CompanyAddress string `json:"companyAddress,omitempty"` + ContactName string `json:"contactName,omitempty"` + ContactPhone string `json:"contactPhone,omitempty" validate:"omitempty,len=11"` + ServiceType string `json:"serviceType,omitempty" validate:"omitempty,oneof=software hardware other"` + IssueDescription string `json:"issueDescription,omitempty"` + ResolutionNote string `json:"resolutionNote,omitempty"` + TechnicianID *uint `json:"technicianId,omitempty"` +} + +// SubmitForConfirmationDTO 提交客户确认请求 +type SubmitForConfirmationDTO struct { + ResolutionNote string `json:"resolutionNote" validate:"required"` +} + +// CustomerConfirmDTO 客户确认请求 +type CustomerConfirmDTO struct { + PhoneLast4 string `json:"phoneLast4" validate:"required,len=4,numeric"` + Action string `json:"action" validate:"required,oneof=authorize reject"` + RejectReason string `json:"rejectReason,omitempty"` +} + +// ReassignAftersalesDTO 重新分配技术员请求 +type ReassignAftersalesDTO struct { + TechnicianID uint `json:"technicianId" validate:"required"` +} + +// AftersalesPublicView 公开查询返回视图(脱敏) +type AftersalesPublicView struct { + SerialNumber string `json:"serialNumber"` + CompanyName string `json:"companyName"` + CompanyAddress string `json:"companyAddress"` + ContactName string `json:"contactName"` + ServiceType string `json:"serviceType"` + IssueDescription string `json:"issueDescription"` + ResolutionNote string `json:"resolutionNote"` + WorkOrderStatus string `json:"workOrderStatus"` + AuthorizationStatus string `json:"authorizationStatus"` + TechnicianName string `json:"technicianName"` + CreatedAt time.Time `json:"createdAt"` + ConfirmedAt *time.Time `json:"confirmedAt"` +} diff --git a/routes/routes.go b/routes/routes.go index 895fbaf..b0936c9 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -73,4 +73,26 @@ func SetupAPIRoutes(r *gin.RouterGroup) { employeeSerialsRoutes.POST("/:serialNumber/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Revoke) employeeSerialsRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Delete) } + + // 售后工单路由 + aftersalesController := controllers.NewAftersalesController() + aftersalesRoutes := r.Group("/aftersales") + { + // 公开(无需登录) + aftersalesRoutes.GET("/:serialNumber/query", aftersalesController.PublicQuery) + aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm) + + // 技术员 + 管理员 + aftersalesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Create) + aftersalesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindAll) + aftersalesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindOne) + aftersalesRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Update) + aftersalesRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.GenerateQRCode) + aftersalesRoutes.POST("/:serialNumber/submit", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.SubmitForConfirmation) + + // 仅管理员 + aftersalesRoutes.POST("/:serialNumber/reassign", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Reassign) + aftersalesRoutes.POST("/:serialNumber/force-close", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.ForceClose) + aftersalesRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Delete) + } } diff --git a/services/aftersales_service.go b/services/aftersales_service.go new file mode 100644 index 0000000..5e3b821 --- /dev/null +++ b/services/aftersales_service.go @@ -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 +}